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,4778 @@
1
+ from typing import TYPE_CHECKING, Literal, overload
2
+ from typing import TypeAlias as _TypeAlias # underscore-aliased: kept out of the module-property registry
3
+
4
+ import math
5
+ import struct
6
+ from abc import ABC, abstractmethod
7
+ from datetime import datetime, UTC
8
+ from collections import deque, defaultdict
9
+ from copy import copy
10
+ from bisect import insort, bisect_left
11
+
12
+ from ...core.module_property import module_property
13
+ from ... import lib
14
+ from .. import syminfo
15
+
16
+ from ...types.strategy import QtyType, ADOPTED_STARTUP_ENTRY_ID
17
+ from ...types.base import IntEnum
18
+ from ...types.na import NA, na_float, na_str
19
+ from ...types import PyneFloat, PyneInt, PyneStr
20
+
21
+ from . import direction as direction
22
+ from . import commission as _commission
23
+ from . import oca as _oca
24
+
25
+ from . import closedtrades, opentrades
26
+
27
+ __all__ = [
28
+ "fixed", "cash", "percent_of_equity",
29
+ "long", "short", 'direction',
30
+
31
+ 'Trade', 'Order', 'PositionBase', 'SimPosition',
32
+ "cancel", "cancel_all", "close", "close_all", "convert_to_account", "convert_to_symbol",
33
+ "default_entry_qty", "entry", "exit", "order",
34
+
35
+ "closedtrades", "opentrades",
36
+ ]
37
+
38
+ #
39
+ # Function-and-namespace modules — the IDE-facing rebinding; at runtime the AST
40
+ # transformer routes bare reads and calls to the module's self-named function
41
+ #
42
+
43
+ from ...types.ohlcv import OHLCV
44
+
45
+ if TYPE_CHECKING:
46
+ from .closedtrades import closedtrades
47
+ from .opentrades import opentrades
48
+ # Static-only public aliases: at runtime the submodule import above already
49
+ # sets these attributes on the package; the underscore aliases keep them out
50
+ # of the module-property registry.
51
+ from . import commission as commission, oca as oca
52
+
53
+
54
+ #
55
+ # Types
56
+ #
57
+
58
+ class _OrderType(IntEnum):
59
+ """ Order type """
60
+
61
+
62
+ #
63
+ # Constants
64
+ #
65
+
66
+ fixed = QtyType("fixed")
67
+ cash = QtyType("cash")
68
+ percent_of_equity = QtyType("percent_of_equity")
69
+
70
+ long = direction.long
71
+ short = direction.short
72
+
73
+ # Possible order types
74
+ _order_type_normal = _OrderType()
75
+ _order_type_entry = _OrderType()
76
+ _order_type_close = _OrderType()
77
+
78
+ # Trailing-stop walk results (see ``SimPosition._process_trailing_stop``)
79
+ _trail_filled = 0
80
+ _trail_deferred = 1
81
+ _trail_pending = 2
82
+
83
+ # Order-book dict key shapes. A close placed by ``strategy.close()`` /
84
+ # ``strategy.close_all()`` in BACKTEST carries a unique ``book_seq`` stamp so
85
+ # that multiple same-bar partial closes on one entry STACK instead of colliding
86
+ # on a shared key; that stamp becomes the optional last tuple element. Sticky
87
+ # ``strategy.exit`` brackets, risk/defensive closes and the live broker path
88
+ # leave ``book_seq`` None and keep the bare 2-/3-tuple key unchanged.
89
+ _ExitOrderKey: _TypeAlias = tuple[str | None, str | None] | tuple[str | None, str | None, int]
90
+ _MarketOrderKey: _TypeAlias = (tuple[_OrderType, str | None, str | None]
91
+ | tuple[_OrderType, str | None, str | None, int])
92
+
93
+ #
94
+ # Imports after constants
95
+ #
96
+
97
+ if True:
98
+ # We need to import this here to avoid circular imports
99
+ from . import risk
100
+
101
+
102
+ #
103
+ # Helpers
104
+ #
105
+
106
+ @overload
107
+ def _na_to_none(value: PyneFloat | NA[float]) -> float | None: ...
108
+
109
+
110
+ @overload
111
+ def _na_to_none(value: PyneStr | NA[str]) -> str | None: ...
112
+
113
+
114
+ def _na_to_none(value): # type: ignore[misc]
115
+ """Convert na (NA object or native nan float) to None, pass through everything else."""
116
+ if isinstance(value, NA) or value != value:
117
+ return None
118
+ return value
119
+
120
+
121
+ def _exit_order_key(order_: 'Order') -> '_ExitOrderKey':
122
+ """Order-book key for an exit/close order.
123
+
124
+ A backtest partial close stamped with a ``book_seq`` (see
125
+ :meth:`PositionBase._next_close_seq`) appends it as a 3rd element so several
126
+ same-bar closes on one entry get distinct keys and STACK; every other order
127
+ (sticky ``strategy.exit``, risk/defensive close, live broker close) keeps the
128
+ bare ``(exit_id, order_id)`` key, leaving their dedup-by-id semantics intact.
129
+ Insert and pop sites MUST both route through this helper so they never drift.
130
+ """
131
+ if order_.book_seq is None:
132
+ return order_.exit_id, order_.order_id
133
+ return order_.exit_id, order_.order_id, order_.book_seq
134
+
135
+
136
+ def _market_order_key(order_: 'Order') -> '_MarketOrderKey':
137
+ """Market-orders key, mirroring :func:`_exit_order_key`'s ``book_seq`` rule."""
138
+ if order_.book_seq is None:
139
+ return order_.order_type, order_.order_id, order_.exit_id
140
+ return order_.order_type, order_.order_id, order_.exit_id, order_.book_seq
141
+
142
+
143
+ #
144
+ # Classes
145
+ #
146
+
147
+ class Order:
148
+ """
149
+ Represents an order
150
+ """
151
+
152
+ __slots__ = (
153
+ "order_id", "size", "sign", "order_type", "limit", "stop", "exit_id", "oca_name", "oca_type",
154
+ "comment", "alert_message",
155
+ "comment_profit", "comment_loss", "comment_trailing",
156
+ "alert_profit", "alert_loss", "alert_trailing",
157
+ "trail_price", "trail_offset",
158
+ "trail_triggered", "trail_stop",
159
+ "profit_ticks", "loss_ticks", "trail_points_ticks", # Store tick values for later calculation
160
+ "is_market_order", # Flag to check if this is a market order
161
+ "cancelled", # Flag to mark order as cancelled by OCA
162
+ "deferred_qty", # Default-sized entry: quantity re-resolves at the actual fill price
163
+ "filled_qty", # Live: quantity of this entry order already reflected in open_trades
164
+ "flip_extra", # Reversal flip magnitude frozen at creation (added back on deferred re-size)
165
+ "bar_index", # Bar index when the order was placed
166
+ "filled_by_type", # Type of execution: 'profit', 'loss', 'trailing', or None
167
+ "from_entry_na", # True if exit was created without explicit from_entry (applies to any position)
168
+ "reserved_size", # Exit-leg slice of the entry's original size (frozen at creation)
169
+ "rest_leg", # Exit leg with no explicit qty/qty_percent: closes the WHOLE bound entry
170
+ "consumed", # True once an exit leg fired its slice while its entry is still open
171
+ "book_seq", # Monotonic stamp for same-bar strategy.close()/close_all() partial closes
172
+ # (backtest only); None for non-stacking sticky-exit / risk / live orders
173
+ )
174
+
175
+ def __init__(
176
+ self,
177
+ order_id: str | None,
178
+ size: PyneFloat,
179
+ *,
180
+ order_type: _OrderType = _order_type_normal,
181
+ exit_id: str | None = None,
182
+ limit: float | None = None,
183
+ stop: float | None = None,
184
+ oca_name: str | None = None,
185
+ oca_type: _oca.Oca | None = _oca.none,
186
+ comment: PyneStr | None = None,
187
+ alert_message: PyneStr | None = None,
188
+ comment_profit: str | None = None,
189
+ comment_loss: str | None = None,
190
+ comment_trailing: str | None = None,
191
+ alert_profit: str | None = None,
192
+ alert_loss: str | None = None,
193
+ alert_trailing: str | None = None,
194
+ trail_price: float | None = None,
195
+ trail_offset: float | None = None,
196
+ profit_ticks: float | None = None,
197
+ loss_ticks: float | None = None,
198
+ trail_points_ticks: float | None = None
199
+ ):
200
+ self.order_id = order_id
201
+ self.size = size
202
+ self.sign = 0.0 if size == 0.0 else 1.0 if size > 0.0 else -1.0
203
+ self.limit = limit
204
+ self.stop = stop
205
+ self.order_type = order_type
206
+
207
+ self.exit_id = exit_id
208
+
209
+ self.oca_name = oca_name
210
+ self.oca_type = oca_type if oca_type is not None else _oca.none
211
+
212
+ self.comment = comment
213
+ self.alert_message = alert_message
214
+ self.comment_profit = comment_profit
215
+ self.comment_loss = comment_loss
216
+ self.comment_trailing = comment_trailing
217
+ self.alert_profit = alert_profit
218
+ self.alert_loss = alert_loss
219
+ self.alert_trailing = alert_trailing
220
+
221
+ self.trail_price = trail_price
222
+ self.trail_offset = trail_offset or 0 # in ticks
223
+ self.trail_triggered = False
224
+ self.trail_stop: float | None = None # active trailing-stop level once triggered
225
+
226
+ self.profit_ticks = profit_ticks
227
+ self.loss_ticks = loss_ticks
228
+ self.trail_points_ticks = trail_points_ticks
229
+
230
+ # Check if this is a market order (no limit, stop, trail, or tick-based prices)
231
+ self.is_market_order = (self.limit is None and self.stop is None
232
+ and self.trail_price is None
233
+ and self.profit_ticks is None
234
+ and self.loss_ticks is None
235
+ and self.trail_points_ticks is None)
236
+
237
+ self.cancelled = False
238
+ self.deferred_qty = False
239
+ # Live-only fill accounting: how much of this retained entry order has
240
+ # already been recorded as an open trade. The simulator removes a
241
+ # market entry order on fill, so it stays 0.0 there; the live broker
242
+ # keeps the entry Order in ``entry_orders`` for intent stability, so the
243
+ # bound-size reservation must not double-count the filled slice.
244
+ self.filled_qty = 0.0
245
+ self.flip_extra = 0.0
246
+ self.bar_index = -1 # Will be set when order is added to position
247
+ self.filled_by_type: Literal['profit', 'loss', 'trailing'] | None = None # Will be set when order fills
248
+ self.from_entry_na = False
249
+ self.reserved_size = abs(size)
250
+ self.rest_leg = False
251
+ self.consumed = False
252
+ # Stamped only by strategy.close()/close_all() in backtest (see _next_close_seq);
253
+ # left None everywhere else so the order-book key keeps its bare shape.
254
+ self.book_seq: int | None = None
255
+
256
+ def __repr__(self):
257
+ return f"Order(order_id={self.order_id}; exit_id={self.exit_id}; size={self.size}; type: {self.order_type}; " \
258
+ f"limit={self.limit}; stop={self.stop}; " \
259
+ f"trail_price={self.trail_price}; trail_offset={self.trail_offset}; " \
260
+ f"oca_name={self.oca_name}; comment={self.comment}; book_seq={self.book_seq}; " \
261
+ f"bar_index={self.bar_index})"
262
+
263
+
264
+ class Trade:
265
+ """
266
+ Represents a trade
267
+ """
268
+
269
+ __slots__ = (
270
+ "size", "init_size", "sign", "entry_id", "entry_bar_index", "entry_time", "entry_price", "entry_comment", "entry_equity",
271
+ "exit_id", "exit_bar_index", "exit_time", "exit_price", "exit_comment", "exit_equity",
272
+ "commission", "max_drawdown", "max_drawdown_percent", "max_runup", "max_runup_percent",
273
+ "profit", "profit_percent", "cum_profit", "cum_profit_percent",
274
+ "cum_max_drawdown", "cum_max_runup"
275
+ )
276
+
277
+ # noinspection PyShadowingNames
278
+ def __init__(self, *, size: PyneFloat, entry_id: str | None, entry_bar_index: int, entry_time: int,
279
+ entry_price: PyneFloat,
280
+ commission: PyneFloat, entry_comment: PyneStr | None = None,
281
+ entry_equity: PyneFloat = 0.0):
282
+ self.size: PyneFloat = size
283
+ # Original entry quantity, frozen — partial exits shrink ``size`` but
284
+ # qty_percent / no-qty "rest" exit legs reserve off this value.
285
+ self.init_size: PyneFloat = size
286
+ self.sign = 0.0 if size == 0.0 else 1.0 if size > 0.0 else -1.0
287
+
288
+ self.entry_id: str | None = entry_id
289
+ self.entry_bar_index: int = entry_bar_index
290
+ self.entry_time: int = entry_time
291
+ self.entry_price: PyneFloat = entry_price
292
+ self.entry_equity: PyneFloat = entry_equity
293
+ self.entry_comment: PyneStr | None = entry_comment
294
+
295
+ self.exit_id: str | None = ""
296
+ self.exit_bar_index: int = -1
297
+ self.exit_time: int = -1
298
+ self.exit_price: PyneFloat = 0.0
299
+ self.exit_comment: PyneStr = ''
300
+ self.exit_equity: PyneFloat = na_float
301
+
302
+ self.commission: PyneFloat = commission
303
+
304
+ self.max_drawdown: PyneFloat = 0.0
305
+ self.max_drawdown_percent: PyneFloat = 0.0
306
+ self.max_runup: PyneFloat = 0.0
307
+ self.max_runup_percent: PyneFloat = 0.0
308
+ self.profit: PyneFloat = 0.0
309
+ self.profit_percent: PyneFloat = 0.0
310
+
311
+ self.cum_profit: PyneFloat = 0.0
312
+ self.cum_profit_percent: PyneFloat = 0.0
313
+ self.cum_max_drawdown: PyneFloat = 0.0
314
+ self.cum_max_runup: PyneFloat = 0.0
315
+
316
+ def __repr__(self):
317
+ return f"Trade(entry_id={self.entry_id}; size={self.size}; entry_bar_index: {self.entry_bar_index}; " \
318
+ f"entry_price={self.entry_price}; exit_price={self.exit_price}; commission={self.commission}; " \
319
+ f"entry_equity={self.entry_equity}; exit_equity={self.exit_equity}"
320
+
321
+ #
322
+ # Support csv.DictWriter
323
+ #
324
+
325
+ def keys(self):
326
+ return self.__dict__.keys()
327
+
328
+ def get(self, key: str, default=None):
329
+ v = getattr(self, key, default)
330
+ if key in ('entry_time', 'exit_time') and isinstance(v, (int, float)):
331
+ v = datetime.fromtimestamp(v / 1000.0, tz=UTC)
332
+ elif isinstance(v, float):
333
+ v = round(v, 10)
334
+ return v
335
+
336
+
337
+ # noinspection PyShadowingNames,DuplicatedCode
338
+ class PriceOrderBook:
339
+ """
340
+ Price-based sorted order storage.
341
+ An order can appear multiple times at different prices.
342
+ """
343
+
344
+ __slots__ = ('price_levels', 'orders_at_price', 'order_prices')
345
+
346
+ def __init__(self):
347
+ self.price_levels: list[float] = [] # Sorted list of prices
348
+ # Plain dict, NOT defaultdict: a stray read must never auto-create an
349
+ # empty bucket. ``price_levels`` (what the intrabar walk iterates) and
350
+ # the keys of ``orders_at_price`` must stay in lock-step; an orphan
351
+ # empty key would make ``add_order`` skip registering a level, silently
352
+ # dropping that leg from the walk. Reads use ``.get(price, ())``.
353
+ self.orders_at_price: dict[float, list[Order]] = {} # price -> [Order]
354
+ self.order_prices: defaultdict[Order, set[float]] = defaultdict(set) # Order -> {prices}
355
+
356
+ def _index_price(self, order: Order, price: float, existing: set) -> None:
357
+ """Register ``order`` at ``price`` in both the level list and the bucket.
358
+
359
+ The level-list insertion is gated on ``price_levels`` itself (the
360
+ structure the walk reads), not on ``orders_at_price``, so the two can
361
+ never desync into a dropped level.
362
+ """
363
+ if price in existing:
364
+ return
365
+ if price not in self.price_levels:
366
+ insort(self.price_levels, price)
367
+ self.orders_at_price.setdefault(price, []).append(order)
368
+ existing.add(price)
369
+
370
+ def add_order(self, order: Order):
371
+ """Add order to all its relevant price levels.
372
+
373
+ Idempotent per (order, price): callers that re-invoke after materializing
374
+ an additional side (e.g. close-pass / `_process_at_bar_open` resolving
375
+ `loss_ticks` on an exit that already had an explicit `limit`) won't
376
+ double-index the side that was already in the book. `remove_order`
377
+ only removes one occurrence per price level, so a duplicate could
378
+ otherwise survive past `_remove_order` and re-fill on the next bar.
379
+ """
380
+ existing = self.order_prices[order]
381
+ if order.stop is not None:
382
+ self._index_price(order, order.stop, existing)
383
+ if order.limit is not None:
384
+ self._index_price(order, order.limit, existing)
385
+ if order.trail_price is not None:
386
+ self._index_price(order, order.trail_price, existing)
387
+
388
+ def remove_order(self, order: Order):
389
+ """Remove order from all price levels"""
390
+ for price in list(self.order_prices[order]):
391
+ bucket = self.orders_at_price.get(price)
392
+ if bucket is not None:
393
+ if order in bucket:
394
+ bucket.remove(order)
395
+ if not bucket:
396
+ idx = bisect_left(self.price_levels, price)
397
+ if idx < len(self.price_levels) and self.price_levels[idx] == price:
398
+ del self.price_levels[idx]
399
+ del self.orders_at_price[price]
400
+ del self.order_prices[order]
401
+
402
+ def iter_orders(self, *, desc=False, min_price: float | None = None, max_price: float | None = None):
403
+ """
404
+ Iterate over orders within price range.
405
+
406
+ Examples:
407
+ iter_orders() # All orders, ascending
408
+ iter_orders(desc=True) # All orders, descending
409
+ iter_orders(min_price=50.0) # 50, 51, 52, ... (ascending)
410
+ iter_orders(max_price=60.0) # 60, 59, 58, ... (descending)
411
+ iter_orders(min_price=50.0, max_price=60.0) # 50, 51, ..., 60 (ascending)
412
+
413
+ :param desc: If True, iterate in descending order, only if no min_price or max_price is set
414
+ :param min_price: If set, iterate from this price upward (ascending)
415
+ :param max_price: If set, iterate from this price downward (descending)
416
+ :return: Generator yielding Order objects
417
+ """
418
+ if min_price is not None and max_price is not None:
419
+ # Range query - ascending from min to max (or descending when desc=True,
420
+ # e.g. the open->low price walk, where the level nearest the open is
421
+ # reached first in time). Price levels reverse; within a level the
422
+ # insertion order is preserved so same-price ties keep their sequence.
423
+ min_idx = bisect_left(self.price_levels, min_price)
424
+ max_idx = bisect_left(self.price_levels, max_price)
425
+ # Include max_price if it matches exactly
426
+ if max_idx < len(self.price_levels) and self.price_levels[max_idx] == max_price:
427
+ max_idx += 1
428
+ # Create a copy of price levels to avoid iteration issues when levels are removed
429
+ levels = list(self.price_levels[min_idx:max_idx])
430
+ if desc:
431
+ levels.reverse()
432
+ for p in levels:
433
+ # Create a copy to avoid iteration issues when orders are removed during iteration
434
+ yield from list(self.orders_at_price.get(p, ()))
435
+
436
+ elif min_price is not None:
437
+ # Ascending from min_price
438
+ min_idx = bisect_left(self.price_levels, min_price)
439
+ # Create a copy of price levels to avoid iteration issues when levels are removed
440
+ for p in list(self.price_levels[min_idx:]):
441
+ # Create a copy to avoid iteration issues when orders are removed during iteration
442
+ yield from list(self.orders_at_price.get(p, ()))
443
+
444
+ elif max_price is not None:
445
+ # Descending from max_price
446
+ max_idx = bisect_left(self.price_levels, max_price)
447
+ # Include max_price if it matches exactly
448
+ if max_idx < len(self.price_levels) and self.price_levels[max_idx] == max_price:
449
+ max_idx += 1
450
+ # Iterate in reverse order (high to low prices)
451
+ # Create a copy of price levels to avoid iteration issues when levels are removed
452
+ # Note: reversed() already creates an iterator over a copy of the slice
453
+ for p in reversed(list(self.price_levels[:max_idx])):
454
+ # Create a copy to avoid iteration issues when orders are removed during iteration
455
+ yield from list(self.orders_at_price.get(p, ()))
456
+
457
+ elif desc:
458
+ # All orders, descending
459
+ # Create a copy of price levels to avoid iteration issues when levels are removed
460
+ for p in reversed(list(self.price_levels)):
461
+ # Create a copy to avoid iteration issues when orders are removed during iteration
462
+ yield from list(self.orders_at_price.get(p, ()))
463
+ else:
464
+ # All orders, ascending
465
+ # Create a copy of price levels to avoid iteration issues when levels are removed
466
+ for p in list(self.price_levels):
467
+ # Create a copy to avoid iteration issues when orders are removed during iteration
468
+ yield from list(self.orders_at_price.get(p, ()))
469
+
470
+ def clear(self):
471
+ """Clear all orders"""
472
+ self.price_levels.clear()
473
+ self.orders_at_price.clear()
474
+ self.order_prices.clear()
475
+
476
+
477
+ # noinspection PyProtectedMember,PyShadowingNames
478
+ class PositionBase(ABC):
479
+ """
480
+ Abstract base class for position tracking.
481
+
482
+ Both backtest simulation (:class:`SimPosition`) and live broker trading
483
+ (:class:`pynecore.core.broker.position.BrokerPosition`) subclass this.
484
+ The Pine Script API surface — ``strategy.position_size``,
485
+ ``strategy.opentrades``, ``strategy.netprofit``, ``strategy.equity``,
486
+ etc. — reads the attributes declared here, so concrete subclasses MUST
487
+ initialize all of them in ``__init__``.
488
+ """
489
+ __slots__ = ('_close_seq_counter',)
490
+
491
+ # Attribute surface (declared for documentation and type-checking only —
492
+ # concrete subclasses declare these in ``__slots__`` and initialize them).
493
+ size: float
494
+ sign: float
495
+ avg_price: PyneFloat
496
+ netprofit: PyneFloat
497
+ openprofit: PyneFloat
498
+ grossprofit: PyneFloat
499
+ grossloss: PyneFloat
500
+ open_commission: float
501
+ # Current-bar OHLC the order-fill checks read off the position
502
+ # (sim tracks them as slots; broker serves them from the live feed).
503
+ c: float
504
+ h: float
505
+ l: float
506
+ eventrades: int
507
+ wintrades: int
508
+ losstrades: int
509
+ closed_trades_count: int
510
+ max_drawdown: float
511
+ max_runup: float
512
+ open_trades: list['Trade']
513
+ closed_trades: 'deque[Trade]'
514
+ new_closed_trades: list['Trade']
515
+ entry_orders: dict[str | None, 'Order']
516
+ exit_orders: dict['_ExitOrderKey', 'Order']
517
+ risk_halt_trading: bool
518
+ # Monotonic counter feeding _next_close_seq(); initialized by each subclass.
519
+ _close_seq_counter: int
520
+
521
+ def _next_close_seq(self) -> int:
522
+ """Return a fresh monotonic stamp for a same-bar partial close.
523
+
524
+ ``strategy.close()`` / ``strategy.close_all()`` use this so that several
525
+ partial closes issued on one bar against the same entry id get DISTINCT
526
+ order-book keys and therefore STACK (all fill) instead of the later call
527
+ silently evicting the earlier one. Backtest only — the live broker path
528
+ leaves ``Order.book_seq`` None (handled in a separate change).
529
+ """
530
+ self._close_seq_counter += 1
531
+ return self._close_seq_counter
532
+
533
+ def begin_evaluation(self) -> None:
534
+ """Hook fired once per script evaluation; overridden in broker mode.
535
+
536
+ :class:`~pynecore.core.broker.position.BrokerPosition` uses it to reset
537
+ its per-evaluation close-netting scope so two same-bar ``strategy.close``
538
+ calls net into one live order. The simulator dispatches nothing live and
539
+ needs no reset, so the base implementation is a no-op.
540
+ """
541
+
542
+ # Risk management state shared by Sim and Broker positions. Setters
543
+ # in :mod:`pynecore.lib.strategy.risk` populate the ``risk_max_*`` fields;
544
+ # the ``risk_*`` runtime counters are updated by the concrete subclass.
545
+ risk_allowed_direction: 'direction.Direction | None'
546
+ risk_max_drawdown_value: float | None
547
+ risk_max_drawdown_type: 'QtyType | None'
548
+ risk_max_drawdown_alert: str | None
549
+ risk_max_intraday_loss_value: float | None
550
+ risk_max_intraday_loss_type: 'QtyType | None'
551
+ risk_max_intraday_loss_alert: str | None
552
+ risk_max_cons_loss_days: int | None
553
+ risk_max_cons_loss_days_alert: str | None
554
+ risk_max_intraday_filled_orders: int | None
555
+ risk_max_intraday_filled_orders_alert: str | None
556
+ risk_max_position_size: float | None
557
+ risk_intraday_start_equity: float
558
+ risk_intraday_filled_orders: int
559
+ risk_cons_loss_days: int
560
+
561
+ @property
562
+ def equity(self) -> PyneFloat:
563
+ """The current equity (initial capital + realized + unrealized P&L)."""
564
+ return lib._script.initial_capital + self.netprofit + self.openprofit
565
+
566
+ # === Risk-rule predicates (shared by Sim and Broker positions) ===
567
+
568
+ def _peak_equity(self) -> float:
569
+ """Reference equity for ``max_drawdown(..., percent_of_equity)``.
570
+
571
+ TradingView measures drawdown from the running peak equity, so the
572
+ percent threshold scales with the high-water mark — a strategy that
573
+ grows from $10k to $20k and is configured with ``max_drawdown(30%)``
574
+ tolerates a $6k drawdown from $20k, not $3k from initial capital.
575
+
576
+ Subclasses that track a peak (``SimPosition.max_equity``) override
577
+ this; the base falls back to initial capital, which matches the
578
+ first-bar value before any equity history exists.
579
+ """
580
+ return float(lib._script.initial_capital)
581
+
582
+ def _is_max_drawdown_breached(self) -> bool:
583
+ if self.risk_max_drawdown_value is None:
584
+ return False
585
+ if self.risk_max_drawdown_type == percent_of_equity:
586
+ threshold = self._peak_equity() * self.risk_max_drawdown_value * 0.01
587
+ else:
588
+ threshold = float(self.risk_max_drawdown_value)
589
+ return self.max_drawdown >= threshold > 0.0
590
+
591
+ def _is_max_intraday_loss_breached(self) -> bool:
592
+ if self.risk_max_intraday_loss_value is None:
593
+ return False
594
+ # Per TV docs: percent_of_equity for max_intraday_loss is measured
595
+ # against the start-of-day equity (the same anchor used for the loss
596
+ # delta), so the threshold scales with the day's opening capital
597
+ # rather than the initial-bar capital.
598
+ if self.risk_max_intraday_loss_type == percent_of_equity:
599
+ threshold = self.risk_intraday_start_equity * self.risk_max_intraday_loss_value * 0.01
600
+ else:
601
+ threshold = float(self.risk_max_intraday_loss_value)
602
+ intraday_loss = self.risk_intraday_start_equity - float(self.equity)
603
+ return intraday_loss >= threshold > 0.0
604
+
605
+ def _is_max_cons_loss_days_breached(self) -> bool:
606
+ if self.risk_max_cons_loss_days is None:
607
+ return False
608
+ return self.risk_cons_loss_days >= self.risk_max_cons_loss_days > 0
609
+
610
+ # === Pre-fill / pre-submit gates (shared by sim fill loop and broker submit) ===
611
+ # These mirror the inline checks in :meth:`SimPosition.fill_order` so that
612
+ # :class:`~pynecore.core.broker.position.BrokerPosition` can enforce the
613
+ # same policy at its pre-submit boundary (``_add_order``) without
614
+ # duplicating the logic. Sim and broker hit the same predicate at
615
+ # different points in the order lifecycle — sim at fill time, broker at
616
+ # submit time — but the rule body is identical.
617
+
618
+ def _is_intraday_filled_cap_reached(self) -> bool:
619
+ """``risk_max_intraday_filled_orders`` already at/above the cap.
620
+
621
+ Caller rejects the new entry/normal order when this returns True.
622
+ Mirrors the sim ``is not None`` check; a stored cap of ``0`` is
623
+ treated as "all orders blocked" by both sites — the
624
+ :mod:`~pynecore.lib.strategy.risk` setter is responsible for
625
+ normalizing the no-limit sentinel.
626
+ """
627
+ cap = self.risk_max_intraday_filled_orders
628
+ if cap is None:
629
+ return False
630
+ return self.risk_intraday_filled_orders >= cap
631
+
632
+ def _adjust_for_max_position_size(
633
+ self, intent_size: float, intent_sign: float,
634
+ ) -> float | None:
635
+ """Honor ``risk_max_position_size``; trim the order or reject it.
636
+
637
+ :param intent_size: Signed order size requested by the caller.
638
+ :param intent_sign: ``+1.0`` for buy intents, ``-1.0`` for sell.
639
+ :return: Possibly trimmed signed size (caller proceeds with this),
640
+ the original ``intent_size`` if no cap is set or no trim
641
+ needed, or ``None`` if the cap is already met and the order
642
+ must be rejected.
643
+ """
644
+ cap = self.risk_max_position_size
645
+ if cap is None:
646
+ return intent_size
647
+ new_position_size = abs(self.size + intent_size)
648
+ if new_position_size <= cap:
649
+ return intent_size
650
+ max_allowed_size = cap - abs(self.size)
651
+ if max_allowed_size <= 0:
652
+ return None
653
+ return max_allowed_size * intent_sign
654
+
655
+ def _is_direction_allowed(self, intent_sign: float) -> bool:
656
+ """``risk_allowed_direction`` permits an entry/flip in this direction.
657
+
658
+ The caller decides *when* to consult this (sim only checks on
659
+ ``size == 0``; broker checks at every submit). The helper itself is
660
+ stateless w.r.t. current position size — it only inspects the
661
+ configured allowed direction.
662
+ """
663
+ allowed = self.risk_allowed_direction
664
+ if allowed is None:
665
+ return True
666
+ if intent_sign > 0:
667
+ return allowed == long
668
+ if intent_sign < 0:
669
+ return allowed == short
670
+ return True
671
+
672
+ def _seed_trail_at_issue(self, order: 'Order', *, fold_extreme: bool = True) -> None:
673
+ """Sim-only hook: fold the issue bar into a freshly issued trailing
674
+ exit's water mark.
675
+
676
+ This is a backtest price-walk concern. The live broker path tracks the
677
+ trailing stop through the exchange / order-sync engine, so the base
678
+ implementation is a no-op; :class:`SimPosition` overrides it with the
679
+ backtest behaviour.
680
+ """
681
+ return None
682
+
683
+ @abstractmethod
684
+ def _add_order(self, order: 'Order') -> None:
685
+ """Register an order with this position."""
686
+
687
+ @abstractmethod
688
+ def _remove_order(self, order: 'Order') -> None:
689
+ """Cancel/remove an order from this position."""
690
+
691
+ @abstractmethod
692
+ def _remove_order_by_id(self, order_id: str) -> None:
693
+ """Remove an order by its id (searches both exit and entry books)."""
694
+
695
+ @abstractmethod
696
+ def _cancel_all_orders(self) -> None:
697
+ """Cancel every pending entry/exit order tracked by this position."""
698
+
699
+
700
+ # noinspection PyProtectedMember,PyShadowingNames,DuplicatedCode
701
+ class SimPosition(PositionBase):
702
+ """
703
+ Backtest simulation of position and trade state.
704
+
705
+ Reproduces TradingView's strategy simulator faithfully: OHLC-based fill
706
+ detection, synthetic slippage, margin-call emulation, gap-through logic,
707
+ OCA reduce/cancel handling, trailing-stop tracking, etc.
708
+
709
+ Live broker trading uses :class:`BrokerPosition` instead — exchange fills
710
+ override all of the simulator logic below.
711
+ """
712
+
713
+ __slots__ = (
714
+ 'h', 'l', 'c', 'o',
715
+ 'netprofit', 'openprofit', 'grossprofit', 'grossloss',
716
+ 'entry_orders', 'exit_orders', 'market_orders', 'orderbook',
717
+ 'open_trades', 'closed_trades', 'new_closed_trades',
718
+ 'closed_trades_count', 'wintrades', 'eventrades', 'losstrades',
719
+ 'size', 'sign', 'avg_price', 'cum_profit',
720
+ 'entry_equity', 'max_equity', 'min_equity',
721
+ 'drawdown_summ', 'runup_summ', 'max_drawdown', 'max_runup',
722
+ 'peak_realized_equity',
723
+ 'unrealized_max_drawdown', 'unrealized_max_drawdown_percent',
724
+ 'real_max_drawdown', 'real_max_drawdown_percent',
725
+ 'entry_summ', 'open_commission',
726
+ 'risk_allowed_direction', 'risk_max_cons_loss_days', 'risk_max_cons_loss_days_alert',
727
+ 'risk_max_drawdown_value', 'risk_max_drawdown_type', 'risk_max_drawdown_alert',
728
+ 'risk_max_intraday_filled_orders', 'risk_max_intraday_filled_orders_alert',
729
+ 'risk_max_intraday_loss_value', 'risk_max_intraday_loss_type', 'risk_max_intraday_loss_alert',
730
+ 'risk_max_position_size',
731
+ 'risk_cons_loss_days', 'risk_last_trading_day', 'risk_last_day_equity',
732
+ 'risk_intraday_filled_orders', 'risk_intraday_start_equity', 'risk_halt_trading',
733
+ '_deferred_margin_call', '_fill_counter', '_partial_close_bar',
734
+ '_entry_open_ledger', '_deferred_immediate_closes'
735
+ )
736
+
737
+ def __init__(self):
738
+ # OHLC values
739
+ self.h: float = 0.0
740
+ self.l: float = 0.0
741
+ self.c: float = 0.0
742
+ self.o: float = 0.0
743
+
744
+ # Profit/loss tracking
745
+ self.netprofit: PyneFloat = 0.0
746
+ self.openprofit: PyneFloat = 0.0
747
+ self.grossprofit: PyneFloat = 0.0
748
+ self.grossloss: PyneFloat = 0.0
749
+
750
+ # Order books
751
+ self.market_orders: dict[_MarketOrderKey, Order] = {} # Market orders from strategy.market()
752
+ self.entry_orders: dict[str | None, Order] = {} # Entry orders from strategy.entry()
753
+ # Exit orders from strategy.exit(), strategy.close(), etc.
754
+ # Key is (exit_id, from_entry) — both partial-TP fan-out (same from_entry,
755
+ # different ids) and from_entry_na fan-out (same id, different from_entry)
756
+ # must coexist; only repeated calls with both fields equal modify-in-place.
757
+ # A backtest strategy.close()/close_all() order additionally carries a
758
+ # book_seq stamp appended as a 3rd key element, so same-bar partial closes
759
+ # on one entry stack instead of evicting each other (see _add_order).
760
+ self.exit_orders: dict[_ExitOrderKey, Order] = {}
761
+ self.orderbook = PriceOrderBook()
762
+
763
+ # Trades
764
+ self.open_trades: list[Trade] = []
765
+ self.closed_trades: deque[Trade] = deque(maxlen=9000) # 9000 is the limit of TV
766
+ self.new_closed_trades: list[Trade] = []
767
+ # Per-entry bound open quantity — drives the exit-order lifecycle (see
768
+ # _reduce_entry_ledger); the trade rows themselves are attributed FIFO.
769
+ self._entry_open_ledger: dict[str, float] = {}
770
+
771
+ # Trade statistics
772
+ self.closed_trades_count: int = 0
773
+ self.wintrades: int = 0
774
+ self.eventrades: int = 0
775
+ self.losstrades: int = 0
776
+ self.size: float = 0.0
777
+ self.sign: float = 0.0
778
+ self.avg_price: PyneFloat = na_float
779
+ self.cum_profit: PyneFloat = 0.0
780
+ self.entry_equity: PyneFloat = 0.0
781
+ self.max_equity: PyneFloat = -float("inf")
782
+ self.min_equity: PyneFloat = float("inf")
783
+ self.drawdown_summ: float = 0.0
784
+ self.runup_summ: float = 0.0
785
+ self.max_drawdown: float = 0.0
786
+ self.max_runup: float = 0.0
787
+ # P5 drawdown accumulators: intrabar-unrealized (worst-case) and real (losing-open)
788
+ self.peak_realized_equity: float = 0.0
789
+ self.unrealized_max_drawdown: float = 0.0
790
+ self.unrealized_max_drawdown_percent: float = 0.0
791
+ self.real_max_drawdown: float = 0.0
792
+ self.real_max_drawdown_percent: float = 0.0
793
+ self.entry_summ: PyneFloat = 0.0
794
+ self.open_commission: float = 0.0
795
+
796
+ # Risk management settings
797
+ self.risk_allowed_direction: direction.Direction | None = None
798
+ self.risk_max_cons_loss_days: int | None = None
799
+ self.risk_max_cons_loss_days_alert: str | None = None
800
+ self.risk_max_drawdown_value: float | None = None
801
+ self.risk_max_drawdown_type: QtyType | None = None
802
+ self.risk_max_drawdown_alert: str | None = None
803
+ self.risk_max_intraday_filled_orders: int | None = None
804
+ self.risk_max_intraday_filled_orders_alert: str | None = None
805
+ self.risk_max_intraday_loss_value: float | None = None
806
+ self.risk_max_intraday_loss_type: QtyType | None = None
807
+ self.risk_max_intraday_loss_alert: str | None = None
808
+ self.risk_max_position_size: float | None = None
809
+
810
+ # Risk management state tracking
811
+ self.risk_cons_loss_days: int = 0
812
+ self.risk_last_trading_day: int = -1
813
+ self.risk_last_day_equity: float = 0.0
814
+ self.risk_intraday_filled_orders: int = 0
815
+ self.risk_intraday_start_equity: float = 0.0
816
+ self.risk_halt_trading: bool = False
817
+
818
+ # Deferred margin call (mc_size==1 and AF@C<0: fire after script runs)
819
+ self._deferred_margin_call: tuple[float, bool] | None = None
820
+ self._fill_counter: int = 0
821
+ # Monotonic stamp source for same-bar stacking of partial closes.
822
+ self._close_seq_counter: int = 0
823
+ # bar_index of the most recent filled partial strategy.close() (a stamped
824
+ # close with an entry id); lets a same-bar close_all clamp to flat instead
825
+ # of overshooting when the partial already shed part of the position.
826
+ self._partial_close_bar: int = -1
827
+ # FIFO buffer of strategy.close/close_all(immediately=True) orders enqueued
828
+ # during the body; drained by settle_immediate_closes() right after the body
829
+ # so position series stay constant for the rest of the bar (TV semantics).
830
+ self._deferred_immediate_closes: list[Order] = []
831
+
832
+ def _add_order(self, order: Order):
833
+ """ Add an order to the strategy """
834
+ # Set the bar_index when the order is placed
835
+ order.bar_index = int(lib.bar_index)
836
+
837
+ # Add market order to market orders dict. Key on exit_id too: two
838
+ # brackets sharing the same from_entry (order_id) would otherwise
839
+ # collide on the same key, so a second gap-through exit would evict
840
+ # the first and only one of them would fill on the gap bar. A stacked
841
+ # partial close additionally keys on book_seq (see _market_order_key).
842
+ if order.is_market_order:
843
+ self.market_orders[_market_order_key(order)] = order
844
+
845
+ # Check if an order with this ID already exists and remove it first
846
+ if order.order_type == _order_type_close:
847
+ exit_key = _exit_order_key(order)
848
+ existing_order = self.exit_orders.get(exit_key)
849
+ self.exit_orders[exit_key] = order
850
+ else:
851
+ # Both entry and normal orders are stored in entry_orders dict
852
+ existing_order = self.entry_orders.get(order.order_id)
853
+ self.entry_orders[order.order_id] = order
854
+
855
+ # Remove existing order from order book before adding new one
856
+ if existing_order is not None:
857
+ self.orderbook.remove_order(existing_order)
858
+
859
+ # Add order to order book (automatically adds to all relevant prices)
860
+ self.orderbook.add_order(order)
861
+
862
+ def _remove_order(self, order: Order):
863
+ """ Remove an order from the strategy """
864
+ order.cancelled = True
865
+ if order.order_type == _order_type_close:
866
+ self.exit_orders.pop(_exit_order_key(order), None)
867
+ else:
868
+ # Both entry and normal orders are stored in entry_orders dict
869
+ self.entry_orders.pop(order.order_id, None)
870
+ # Remove market order from market orders dict
871
+ if order.is_market_order:
872
+ self.market_orders.pop(_market_order_key(order), None)
873
+ # Remove order from order book
874
+ self.orderbook.remove_order(order)
875
+
876
+ def _remove_order_by_id(self, order_id: str):
877
+ """ Remove order by id """
878
+ # TV-verified semantics (FX:EURUSD 60min, 2026-05-04): cancel matches an exit
879
+ # by its exit_id only, and an entry by its entry id. NO cross-matching —
880
+ # cancel(entry_id) does not cascade to exits that referenced it via from_entry.
881
+ for exit_order in list(self.exit_orders.values()):
882
+ if exit_order.exit_id == order_id:
883
+ self._remove_order(exit_order)
884
+
885
+ order = self.entry_orders.get(order_id)
886
+ if order:
887
+ self._remove_order(order)
888
+
889
+ def _cancel_all_orders(self) -> None:
890
+ self.entry_orders.clear()
891
+ self.exit_orders.clear()
892
+ self.orderbook.clear()
893
+
894
+ def _cancel_oca_group(self, oca_name: str, executed_order: Order):
895
+ """Cancel all orders in the same OCA group except the executed one"""
896
+ # Cancel entry orders in the same OCA group
897
+ for order in list(self.entry_orders.values()):
898
+ if order.oca_name == oca_name and order != executed_order:
899
+ self._remove_order(order)
900
+
901
+ # Cancel exit orders in the same OCA group (consumed tombstones are
902
+ # retired — they keep their reservation until the entry fully closes)
903
+ for order in list(self.exit_orders.values()):
904
+ if order.oca_name == oca_name and order != executed_order and not order.consumed:
905
+ self._remove_order(order)
906
+
907
+ def _reduce_oca_group(self, oca_name: str, filled_size: PyneFloat):
908
+ """Reduce the size of all orders in the same OCA group"""
909
+ reduction = abs(filled_size)
910
+
911
+ # Reduce entry orders
912
+ for order in list(self.entry_orders.values()):
913
+ if order.oca_name == oca_name and not order.cancelled:
914
+ new_size = abs(order.size) - reduction
915
+ if new_size <= 0:
916
+ # Mark order as cancelled if size would be 0 or negative
917
+ self._remove_order(order)
918
+ else:
919
+ # Keep original sign
920
+ order.size = new_size * order.sign
921
+
922
+ # Reduce exit orders (skip consumed tombstones: a leg that fired its
923
+ # slice is retired and keeps its reservation until the entry closes)
924
+ for order in list(self.exit_orders.values()):
925
+ if order.oca_name == oca_name and not order.cancelled and not order.consumed:
926
+ new_size = abs(order.size) - reduction
927
+ if new_size <= 0:
928
+ self._remove_order(order)
929
+ else:
930
+ order.size = new_size * order.sign
931
+
932
+ def _reduce_entry_ledger(self, entry_id: str | None, qty: float) -> None:
933
+ """Settle a closing fill against the entry it was bound to.
934
+
935
+ TradingView keeps two ledgers: closed TRADES are attributed FIFO
936
+ across the whole position, but each exit/close order still settles
937
+ against its own ``from_entry``. Only when that bound quantity is
938
+ exhausted are the entry's remaining exit legs cancelled — a bracket
939
+ survives its entry's trade rows being consumed FIFO by another
940
+ entry's close, and conversely dies once its entry's quantity is
941
+ spent even while those rows still sit open under other entries.
942
+ """
943
+ if entry_id is None:
944
+ return
945
+ left = self._entry_open_ledger.get(entry_id)
946
+ if left is None:
947
+ return
948
+ left -= qty
949
+ if _size_round(left) <= 0.0:
950
+ del self._entry_open_ledger[entry_id]
951
+ for exit_order in list(self.exit_orders.values()):
952
+ if exit_order.order_id == entry_id:
953
+ self._remove_order(exit_order)
954
+ else:
955
+ self._entry_open_ledger[entry_id] = left
956
+
957
+ def _fill_order(self, order: Order, price: PyneFloat, h: PyneFloat, l: PyneFloat,
958
+ counts_as_filled_order: bool = True):
959
+ """
960
+ Fill an order (actually)
961
+
962
+ :param order: The order to fill
963
+ :param price: The price to fill at
964
+ :param h: The high price
965
+ :param l: The low price
966
+ :param counts_as_filled_order: Whether this fill increments the
967
+ ``max_intraday_filled_orders`` counter.
968
+ ``False`` for the open half of a
969
+ position-reversing order, whose close
970
+ half already counted it once — TV treats
971
+ a reversal as a single filled order.
972
+ """
973
+ # Close orders cannot fill when no position exists
974
+ if order.order_type == _order_type_close and self.size == 0.0:
975
+ return
976
+
977
+ # Record same-bar partial strategy.close() fills (stamped close carrying an
978
+ # entry id) so a later same-bar close_all clamps to flat instead of
979
+ # overshooting on the size it captured before this partial shed part of it.
980
+ # Only a fill that actually sheds size arms the marker: a consumed/zero-size
981
+ # tombstone (a fired partial-exit leg kept alive while its entry stays open)
982
+ # is re-filled as a no-op every bar and must NOT re-arm it, or it would
983
+ # wrongly clamp an unrelated deferred-margin-call close_all overshoot.
984
+ if (order.order_type == _order_type_close and order.order_id is not None
985
+ and order.book_seq is not None
986
+ and not order.consumed and _size_round(order.size) != 0.0):
987
+ self._partial_close_bar = int(lib.bar_index)
988
+
989
+ self._fill_counter += 1
990
+
991
+ # Save the original order size before any modifications
992
+ filled_size = abs(order.size)
993
+
994
+ script = lib._script
995
+ commission_type = script.commission_type
996
+ commission_value = script.commission_value
997
+ # USD value per 1.0-point move per 1 contract — futures-aware PnL conversion factor
998
+ pv = syminfo.pointvalue
999
+
1000
+ new_closed_trades = []
1001
+ closed_trade_size = 0.0
1002
+
1003
+ # Close order - if it is an exit order or a normal order
1004
+ if self.size and order.sign != self.sign:
1005
+ delete = False
1006
+
1007
+ # Check list of open trades.
1008
+ # close_entries_rule='ANY': an entry-bound close consumes only its
1009
+ # own entry's trades. FIFO (TV default): a closing fill consumes
1010
+ # open trades oldest-first regardless of the from_entry binding —
1011
+ # the binding only sizes the order and gates its activation.
1012
+ close_any = (order.order_type == _order_type_close and order.order_id is not None
1013
+ and script.close_entries_rule == 'ANY')
1014
+ new_open_trades = []
1015
+ for trade in self.open_trades:
1016
+ if order.size != 0.0 and (not close_any or trade.entry_id == order.order_id):
1017
+ delete = True
1018
+
1019
+ size = order.size if abs(order.size) <= abs(trade.size) else -trade.size
1020
+ pnl = -size * (price - trade.entry_price) * pv
1021
+
1022
+ # Copy and modify actual trade, because it can be partially filled
1023
+ closed_trade = copy(trade)
1024
+
1025
+ size_ratio = 1 + size / closed_trade.size
1026
+ if closed_trade.size != -size:
1027
+ # Modify commission
1028
+ trade.commission *= size_ratio
1029
+ closed_trade.commission *= (1 - size_ratio)
1030
+ # Modify drawdown and runup
1031
+ trade.max_drawdown *= size_ratio
1032
+ trade.max_runup *= size_ratio
1033
+ closed_trade.max_drawdown *= (1 - size_ratio)
1034
+ closed_trade.max_runup *= (1 - size_ratio)
1035
+
1036
+ # P/L from high/low to calculate drawdown and runup
1037
+ hprofit = (-size * (h - closed_trade.entry_price) * pv - closed_trade.commission)
1038
+ lprofit = (-size * (l - closed_trade.entry_price) * pv - closed_trade.commission)
1039
+
1040
+ # Drawdown and runup
1041
+ drawdown = -min(hprofit, lprofit, 0.0)
1042
+ runup = max(hprofit, lprofit, 0.0)
1043
+ # Drawdown summ runup summ
1044
+ self.drawdown_summ += drawdown
1045
+ self.runup_summ += runup
1046
+
1047
+ closed_trade.size = -size
1048
+ closed_trade.exit_id = order.exit_id if order.exit_id is not None else order.order_id
1049
+ closed_trade.exit_bar_index = int(lib.bar_index)
1050
+ closed_trade.exit_time = lib._time
1051
+ closed_trade.exit_price = price
1052
+ closed_trade.profit = pnl
1053
+
1054
+ # Add to closed trade
1055
+ new_closed_trades.append(closed_trade)
1056
+ self.closed_trades.append(closed_trade)
1057
+ self.closed_trades_count += 1
1058
+
1059
+ # Select appropriate comment based on filled_by_type
1060
+ if order.filled_by_type == 'profit' and order.comment_profit:
1061
+ closed_trade.exit_comment = order.comment_profit
1062
+ elif order.filled_by_type == 'loss' and order.comment_loss:
1063
+ closed_trade.exit_comment = order.comment_loss
1064
+ elif order.filled_by_type == 'trailing' and order.comment_trailing:
1065
+ closed_trade.exit_comment = order.comment_trailing
1066
+ elif order.comment:
1067
+ closed_trade.exit_comment = order.comment
1068
+
1069
+ # Commission summ
1070
+ self.open_commission -= closed_trade.commission
1071
+
1072
+ # cash_per_order is a flat fee per order: defer realization
1073
+ # until the order is removed so it can be split across all
1074
+ # closed trades it actually filled (see delete block below).
1075
+ if commission_type == _commission.cash_per_order:
1076
+ closed_trade_size += abs(size)
1077
+ else:
1078
+ # Calculate exit commission based on commission type
1079
+ if commission_type == _commission.percent:
1080
+ # For percentage commission, multiply by exit price
1081
+ commission = abs(size) * price * pv * commission_value * 0.01
1082
+ else:
1083
+ # cash_per_contract: size-proportional, charged per leg
1084
+ commission = abs(size) * commission_value
1085
+
1086
+ closed_trade.commission += commission
1087
+ # Realize commission
1088
+ self.netprofit -= commission
1089
+ closed_trade.profit -= closed_trade.commission
1090
+
1091
+ # Profit percent — both profit and entry_value are in USD
1092
+ entry_value = abs(closed_trade.size) * closed_trade.entry_price * pv
1093
+ try:
1094
+ # Use closed_trade.profit which includes commission, not pnl which doesn't
1095
+ closed_trade.profit_percent = (closed_trade.profit / entry_value) * 100.0
1096
+ except ZeroDivisionError:
1097
+ closed_trade.profit_percent = 0.0
1098
+
1099
+ # Realize profit or loss
1100
+ self.netprofit += pnl
1101
+
1102
+ # Modify sizes
1103
+ self.size += size
1104
+ # Handle too small sizes because of floating point inaccuracy and rounding
1105
+ position_flat = _size_round(self.size) == 0.0
1106
+ if position_flat:
1107
+ size -= self.size
1108
+ self.size = 0.0
1109
+ self.sign = 0.0 if self.size == 0.0 else 1.0 if self.size > 0.0 else -1.0
1110
+ trade.size += size
1111
+ if position_flat:
1112
+ # `size` already absorbed the position residual above, so the
1113
+ # trade that flattened the position is fully closed. Snap off
1114
+ # float epsilon so it is removed from open_trades instead of
1115
+ # lingering as a ~0-size ghost trade — a stale leg would force
1116
+ # avg_price to NA and poison equity (and every subsequent
1117
+ # percent-of-equity sizing) on later bars.
1118
+ trade.size = 0.0
1119
+ order.size -= size
1120
+
1121
+ # Gross P/L and counters
1122
+ if closed_trade.profit == 0.0:
1123
+ self.eventrades += 1
1124
+ elif closed_trade.profit > 0.0:
1125
+ self.wintrades += 1
1126
+ self.grossprofit += closed_trade.profit
1127
+ else:
1128
+ self.losstrades += 1
1129
+ self.grossloss -= closed_trade.profit
1130
+
1131
+ # Average entry price
1132
+ if self.size:
1133
+ self.entry_summ -= closed_trade.entry_price * abs(closed_trade.size)
1134
+ self.avg_price = self.entry_summ / abs(self.size)
1135
+
1136
+ # Unrealized P&L
1137
+ self.openprofit = self.size * (self.c - self.avg_price) * pv
1138
+ else:
1139
+ # If position has just closed
1140
+ self.avg_price = na_float
1141
+ self.openprofit = 0.0
1142
+
1143
+ # Exit equity
1144
+ closed_trade.exit_equity = self.equity
1145
+
1146
+ # Remove from open trades if it is fully filled
1147
+ if trade.size == 0.0:
1148
+ continue
1149
+
1150
+ if pnl > 0.0:
1151
+ # Modify summs and entry equity with commission
1152
+ self.runup_summ -= closed_trade.commission
1153
+ self.drawdown_summ += closed_trade.commission / 2
1154
+ self.entry_equity += closed_trade.commission / 2
1155
+
1156
+ new_open_trades.append(trade)
1157
+
1158
+ self.open_trades = new_open_trades
1159
+
1160
+ # Settle the closed quantity against the entry ledger. A close
1161
+ # bound to a from_entry settles that entry regardless of which
1162
+ # trades the FIFO fill consumed; an unbound close (close_all,
1163
+ # reversal, margin call) settles entries oldest-first, mirroring
1164
+ # the trade rows.
1165
+ closed_qty = filled_size - abs(order.size)
1166
+ if closed_qty > 0.0:
1167
+ if order.order_type == _order_type_close and order.order_id is not None:
1168
+ self._reduce_entry_ledger(order.order_id, closed_qty)
1169
+ else:
1170
+ for eid in list(self._entry_open_ledger):
1171
+ if closed_qty <= 0.0:
1172
+ break
1173
+ take = min(self._entry_open_ledger[eid], closed_qty)
1174
+ self._reduce_entry_ledger(eid, take)
1175
+ closed_qty -= take
1176
+
1177
+ if delete:
1178
+ # A partial-exit leg that fired its whole slice while its entry's
1179
+ # bound quantity is still open becomes a tombstone: kept in
1180
+ # exit_orders (so its reservation still counts against sibling
1181
+ # "rest" legs and a per-bar strategy.exit() re-call cannot
1182
+ # resurrect it) and only pulled from the order book. It is purged
1183
+ # when the entry's bound quantity is exhausted (_reduce_entry_ledger).
1184
+ if (order.order_type == _order_type_close and order.order_id is not None
1185
+ and _size_round(order.size) == 0.0
1186
+ and self._entry_open_ledger.get(order.order_id, 0.0) > 0.0):
1187
+ order.consumed = True
1188
+ self.orderbook.remove_order(order)
1189
+ else:
1190
+ self._remove_order(order)
1191
+
1192
+ if commission_type == _commission.cash_per_order:
1193
+ # Realize commission
1194
+ self.netprofit -= commission_value
1195
+ for trade in new_closed_trades:
1196
+ commission = (commission_value * abs(trade.size)) / closed_trade_size
1197
+ trade.commission += commission
1198
+
1199
+ self.new_closed_trades.extend(new_closed_trades)
1200
+
1201
+ # close_all overshoot: when deferred MC reduced position, close_all
1202
+ # captures original size and overshoots → create opposite position
1203
+ if (order.order_id is None and order.size != 0.0 and
1204
+ order.order_type == _order_type_close):
1205
+ entry_id = order.exit_id
1206
+ overshoot_trade = Trade(
1207
+ size=order.size,
1208
+ entry_id=entry_id, entry_bar_index=int(lib.bar_index),
1209
+ entry_time=lib._time, entry_price=price,
1210
+ commission=0.0, entry_comment=order.comment,
1211
+ entry_equity=self.equity
1212
+ )
1213
+ self.open_trades.append(overshoot_trade)
1214
+ if entry_id is not None:
1215
+ self._entry_open_ledger[entry_id] = (
1216
+ self._entry_open_ledger.get(entry_id, 0.0) + abs(overshoot_trade.size))
1217
+ self.size += overshoot_trade.size
1218
+ self.sign = 1.0 if self.size > 0.0 else -1.0 if self.size < 0.0 else 0.0
1219
+ self.entry_summ = price * abs(overshoot_trade.size)
1220
+ self.avg_price = price
1221
+ self.openprofit = self.size * (self.c - self.avg_price) * pv
1222
+ if not new_closed_trades:
1223
+ self.entry_equity = self.equity
1224
+ self.max_equity = max(self.max_equity, self.equity)
1225
+ self.min_equity = min(self.min_equity, self.equity)
1226
+
1227
+ # New trade
1228
+ elif order.order_type != _order_type_close:
1229
+ # Calculate commission
1230
+ if commission_value:
1231
+ if commission_type == _commission.cash_per_order:
1232
+ commission = commission_value
1233
+ elif commission_type == _commission.percent:
1234
+ commission = abs(order.size) * price * pv * commission_value * 0.01
1235
+ elif commission_type == _commission.cash_per_contract:
1236
+ commission = abs(order.size) * commission_value
1237
+ else: # Should not be here!
1238
+ assert False, 'Wrong commission type: ' + str(commission_type)
1239
+ else:
1240
+ commission = 0.0
1241
+
1242
+ before_equity = self.equity
1243
+
1244
+ # Realize commission
1245
+ self.netprofit -= commission
1246
+
1247
+ entry_equity = self.equity
1248
+ if not self.open_trades:
1249
+ # Set max and min equity
1250
+ self.max_equity = max(self.max_equity, entry_equity)
1251
+ self.min_equity = min(self.min_equity, entry_equity)
1252
+ # Entry equity
1253
+ self.entry_equity = entry_equity
1254
+
1255
+ # For close_all overshoot, use exit_id as entry_id
1256
+ entry_id = order.order_id if order.order_id is not None else order.exit_id
1257
+
1258
+ trade = Trade(
1259
+ size=order.size,
1260
+ entry_id=entry_id, entry_bar_index=int(lib.bar_index),
1261
+ entry_time=lib._time, entry_price=price,
1262
+ commission=commission, entry_comment=order.comment,
1263
+ entry_equity=before_equity
1264
+ )
1265
+
1266
+ self.open_trades.append(trade)
1267
+ if entry_id is not None:
1268
+ self._entry_open_ledger[entry_id] = (
1269
+ self._entry_open_ledger.get(entry_id, 0.0) + abs(order.size))
1270
+ self.size += trade.size
1271
+ self.sign = 0.0 if self.size == 0.0 else 1.0 if self.size > 0.0 else -1.0
1272
+
1273
+ # Average entry price
1274
+ self.entry_summ += price * abs(order.size)
1275
+ try:
1276
+ self.avg_price = self.entry_summ / abs(self.size)
1277
+ except ZeroDivisionError:
1278
+ self.avg_price = na_float
1279
+ # Unrealized P&L
1280
+ self.openprofit = self.size * (self.c - self.avg_price) * pv
1281
+ # Commission summ
1282
+ self.open_commission += commission
1283
+
1284
+ # Remove order
1285
+ self._remove_order(order)
1286
+
1287
+ # If position has just closed
1288
+ if not self.open_trades:
1289
+ # Reset position variables
1290
+ self.entry_summ = 0.0
1291
+ self.avg_price = na_float
1292
+ self.openprofit = 0.0
1293
+ self.open_commission = 0.0
1294
+ self._entry_open_ledger.clear()
1295
+
1296
+ # Cancel all exit orders when position is closed (TradingView behavior)
1297
+ # Skip exits that have a pending entry (needed during position flips)
1298
+ exit_orders_to_remove = list(self.exit_orders.values())
1299
+ for exit_order in exit_orders_to_remove:
1300
+ if exit_order.order_id in self.entry_orders:
1301
+ continue
1302
+ self._remove_order(exit_order)
1303
+
1304
+ # Count this fill toward strategy.risk.max_intraday_filled_orders.
1305
+ # TradingView counts every filled order (entry, exit, normal) toward the
1306
+ # limit, but a position-reversing order is a SINGLE filled order even
1307
+ # though the sim executes it as a close followed by an open — the open
1308
+ # half passes counts_as_filled_order=False so the reversal counts once.
1309
+ if counts_as_filled_order:
1310
+ self.risk_intraday_filled_orders += 1
1311
+
1312
+ # Handle OCA groups after order execution
1313
+ # This is done here to avoid code duplication in fill_order()
1314
+ if order.oca_name and order.oca_type:
1315
+ if order.oca_type == _oca.cancel:
1316
+ self._cancel_oca_group(order.oca_name, order)
1317
+ elif order.oca_type == _oca.reduce:
1318
+ # Use the saved original filled_size from the beginning of this method
1319
+ self._reduce_oca_group(order.oca_name, filled_size)
1320
+
1321
+ def fill_order(self, order: Order, price: float, h: float, l: float) -> bool:
1322
+ """
1323
+ Fill an order
1324
+
1325
+ :param order: The order to fill
1326
+ :param price: The price to fill at
1327
+ :param h: The high price
1328
+ :param l: The low price
1329
+ :return: True if the side of the position has changed
1330
+ """
1331
+ close_only = False
1332
+ # Apply risk management only to entry orders, not normal orders from strategy.order()
1333
+ if order.order_type == _order_type_entry or order.order_type == _order_type_normal:
1334
+ # A default-sized order settles its quantity at the actual fill price
1335
+ if order.deferred_qty:
1336
+ self._resolve_deferred_qty(order, price)
1337
+ if order.size == 0.0:
1338
+ self._remove_order(order)
1339
+ return False
1340
+ # Pre-fill risk gates — shared with BrokerPosition pre-submit so
1341
+ # the same policy applies regardless of execution mode.
1342
+ if self._is_intraday_filled_cap_reached():
1343
+ self._remove_order(order)
1344
+ return False
1345
+ adjusted = self._adjust_for_max_position_size(float(order.size), order.sign)
1346
+ if adjusted is None:
1347
+ self._remove_order(order)
1348
+ return False
1349
+ order.size = adjusted
1350
+ if self.size == 0.0 and not self._is_direction_allowed(order.sign):
1351
+ self._remove_order(order)
1352
+ return False
1353
+
1354
+ if order.order_type == _order_type_entry:
1355
+ # If we have an existing position
1356
+ if self.size != 0.0:
1357
+ # Check if the order has the same direction
1358
+ if self.sign == order.sign:
1359
+ # Check pyramiding limit for entry orders adding to existing position
1360
+ if lib._script.pyramiding <= len(self.open_trades):
1361
+ # Pyramiding limit reached - don't fill the entry order
1362
+ self._remove_order(order)
1363
+ return False
1364
+
1365
+ # For normal orders (_order_type_normal), no special risk management or pyramiding limits apply
1366
+ # They simply add to or subtract from the position as requested
1367
+
1368
+ # If position direction is about to change, we split it into two separate orders
1369
+ # This is necessary to create a new average entry price
1370
+ # Note: The flip quantity is already calculated in entry() for entry orders
1371
+ new_size = self.size + order.size
1372
+ if _size_round(new_size) == 0.0:
1373
+ new_size = 0.0
1374
+ new_sign = 0.0 if new_size == 0.0 else 1.0 if new_size > 0.0 else -1.0
1375
+ if self.size != 0.0 and new_sign != self.sign and new_size != 0.0:
1376
+ # Exit orders should never reverse position direction; only entry
1377
+ # orders open or reverse. A close_all (order_id None) is normally
1378
+ # allowed to overshoot — a deferred margin call can shrink the
1379
+ # position after close_all captured its size, and TV opens the
1380
+ # overshoot as an opposite trade. But when the shrink came from a
1381
+ # same-bar partial strategy.close() (which stamps book_seq), TV
1382
+ # closes only what remains, so clamp to flat instead of reversing.
1383
+ if (order.order_type == _order_type_close or close_only) and (
1384
+ order.order_id is not None
1385
+ or self._partial_close_bar == int(lib.bar_index)):
1386
+ # Limit the exit order size to just close the position
1387
+ order.size = -self.size
1388
+ self._fill_order(order, price, h, l)
1389
+ return False
1390
+
1391
+ # Create a copy for closing existing position
1392
+ order1 = copy(order)
1393
+ order1.order_type = _order_type_close
1394
+ order1.size = -self.size
1395
+ # Set order_id to None so it will close any open trades
1396
+ order1.order_id = None
1397
+ # The exit_id will be the order_id of the original order
1398
+ order1.exit_id = order.order_id
1399
+ # Fill the closing order first
1400
+ self._fill_order(order1, price, h, l)
1401
+
1402
+ # Check if new direction is allowed by risk management
1403
+ # According to Pine Script docs: "long exit trades will be made instead of reverse trades"
1404
+ new_direction_sign = 1.0 if new_size > 0.0 else -1.0
1405
+ if not self._is_direction_allowed(new_direction_sign):
1406
+ # Direction not allowed - convert entry to exit only
1407
+ # Don't open new position in restricted direction
1408
+ self._remove_order(order)
1409
+ return False
1410
+
1411
+ # Modify the original order to open a position in the new direction
1412
+ order.size = new_size
1413
+ # close_all overshoot: change type to allow opening new trade
1414
+ if order.order_type == _order_type_close:
1415
+ order.order_type = _order_type_normal
1416
+ # Fill the entry order. The close half above already counted this
1417
+ # reversal toward the intraday filled-orders cap, so the open half
1418
+ # must not count it a second time.
1419
+ self._fill_order(order, price, h, l, counts_as_filled_order=False)
1420
+ # A reversal that hits the cap is flattened too — same as the
1421
+ # non-flip path. Without this the cap-close never fires for a
1422
+ # position-reversing strategy, the common TradingView idiom.
1423
+ if self._is_intraday_filled_cap_reached() and self.size != 0.0:
1424
+ self._close_position_at_intraday_cap(order, price)
1425
+ return True
1426
+
1427
+ # If position direction is not about to change, we can fill the order directly
1428
+ else:
1429
+ self._fill_order(order, price, h, l)
1430
+
1431
+ # After filling, close the position if this fill hit the intraday cap
1432
+ # (TradingView flattens for the rest of the day; the counter blocks
1433
+ # new entries until it resets next day).
1434
+ if self._is_intraday_filled_cap_reached() and self.size != 0.0:
1435
+ self._close_position_at_intraday_cap(order, price)
1436
+
1437
+ return False
1438
+
1439
+ def _peak_equity(self) -> float:
1440
+ """Running high-water mark equity for percent-based drawdown threshold.
1441
+
1442
+ Falls back to initial capital before any fill — ``max_equity`` is
1443
+ ``-inf`` until the first ``_fill_order`` updates it.
1444
+ """
1445
+ initial = float(lib._script.initial_capital)
1446
+ if self.max_equity == -float("inf"):
1447
+ return initial
1448
+ return max(initial, float(self.max_equity))
1449
+
1450
+ def _trigger_risk_halt(self, reason: str, price: float, h: float, l: float) -> None:
1451
+ """Cancel pending orders, close any open position at ``price``, halt trading.
1452
+
1453
+ ``reason`` is embedded in the synthetic close order's comment so the
1454
+ backtest log identifies which ``strategy.risk.*`` rule fired. Once
1455
+ :attr:`risk_halt_trading` is set, ``strategy.entry`` / ``strategy.order``
1456
+ early-return, ``process_orders`` short-circuits, and the strategy stays
1457
+ flat until the script completes.
1458
+ """
1459
+ self.entry_orders.clear()
1460
+ self.exit_orders.clear()
1461
+ self.orderbook.clear()
1462
+ if self.size != 0.0:
1463
+ close_order = Order(
1464
+ None, -self.size,
1465
+ exit_id='Risk management close',
1466
+ order_type=_order_type_close,
1467
+ comment=f"Close Position ({reason})",
1468
+ )
1469
+ self._fill_order(close_order, price, h, l)
1470
+ self.risk_halt_trading = True
1471
+
1472
+ def _close_position_at_intraday_cap(self, order: Order, price: float) -> None:
1473
+ """Flatten the position when ``max_intraday_filled_orders`` is reached.
1474
+
1475
+ TradingView closes the open position the moment the daily filled-orders
1476
+ cap is hit, tagging the exit ``Close Position (Max number of filled
1477
+ orders in one day)``. Unlike :meth:`_trigger_risk_halt` this does NOT
1478
+ set :attr:`risk_halt_trading`: the cap is a per-day limit, and the
1479
+ intraday counter (already at the cap) blocks any further entry fills
1480
+ until it resets at the next day rollover, so trading resumes by itself
1481
+ the following day. The forced close is not itself a strategy order, so
1482
+ it does not count toward the cap.
1483
+
1484
+ The exit price mirrors TradingView's broker emulation. When the
1485
+ cap-triggering fill is a market/stop *entry* that fired intra-bar — past
1486
+ the bar open on the favorable side (a long stop above the open, a short
1487
+ stop below it) — TV traces the bar path to that extreme and closes there
1488
+ (bar high for a long, bar low for a short), not at the entry trigger
1489
+ price. Fills that landed at the open (gaps, plain market entries) and
1490
+ non-entry fills close at the triggering fill price.
1491
+ """
1492
+ self.entry_orders.clear()
1493
+ self.exit_orders.clear()
1494
+ self.orderbook.clear()
1495
+ if self.size != 0.0:
1496
+ # ``self.h`` / ``self.l`` are the full current-bar extremes; the ``h`` / ``l``
1497
+ # arguments threaded through :meth:`fill_order` are truncated to the stop
1498
+ # trigger as the intra-bar path is walked, so they cannot stand in for the
1499
+ # bar's reached extreme here.
1500
+ cap_close_price = price
1501
+ if order.order_type == _order_type_entry or order.order_type == _order_type_normal:
1502
+ if self.size > 0.0 and price > self.o:
1503
+ cap_close_price = self.h
1504
+ elif self.size < 0.0 and price < self.o:
1505
+ cap_close_price = self.l
1506
+ close_order = Order(
1507
+ None, -self.size,
1508
+ exit_id='Risk management close',
1509
+ order_type=_order_type_close,
1510
+ comment="Close Position (Max number of filled orders in one day)",
1511
+ )
1512
+ self._fill_order(close_order, cap_close_price, self.h, self.l, counts_as_filled_order=False)
1513
+
1514
+ def _enforce_post_bar_risk(self) -> None:
1515
+ """Run the post-bar ``strategy.risk.*`` checks that depend on bar-end P&L.
1516
+
1517
+ ``max_intraday_filled_orders`` is enforced inline in :meth:`fill_order`
1518
+ because it is fill-count driven; the rules below need the finalised
1519
+ bar P&L (``max_drawdown``) or daily realised equity
1520
+ (``max_intraday_loss``, ``max_cons_loss_days``) and therefore run after
1521
+ :meth:`_finalize_bar_pnl`. The first triggered rule wins — subsequent
1522
+ checks are skipped, since a halt closes all positions and clears
1523
+ pending orders.
1524
+ """
1525
+ if self.risk_halt_trading:
1526
+ return
1527
+ # Use the bar-close price for the synthetic close — the bar is over.
1528
+ price, h, l = self.c, self.h, self.l
1529
+ if self._is_max_drawdown_breached():
1530
+ self._trigger_risk_halt("Max drawdown reached", price, h, l)
1531
+ return
1532
+ if self._is_max_intraday_loss_breached():
1533
+ self._trigger_risk_halt("Max intraday loss reached", price, h, l)
1534
+ return
1535
+ if self._is_max_cons_loss_days_breached():
1536
+ self._trigger_risk_halt("Max consecutive loss days reached", price, h, l)
1537
+
1538
+ def _check_already_filled(self, order: Order) -> bool:
1539
+ """
1540
+ Check if a stop or limit order would be immediately fillable due to a gap.
1541
+ This is called during process_orders when we have the current bar's OHLC values.
1542
+
1543
+ When there's a gap, orders that would normally wait for price movement
1544
+ should execute immediately at the open price.
1545
+
1546
+ :param order: The order to check
1547
+ :return: True if the order should be filled immediately at open price
1548
+ """
1549
+ # if not self.open_trades:
1550
+ # return False
1551
+
1552
+ # Check stop orders with gaps
1553
+ if order.stop is not None:
1554
+ # Long stop order (size > 0): triggers if open gaps above stop level
1555
+ if order.size > 0 and self.o >= order.stop:
1556
+ return True
1557
+ # Short stop order (size < 0): triggers if open gaps below stop level
1558
+ if order.size < 0 and self.o <= order.stop:
1559
+ return True
1560
+
1561
+ # Check limit orders with gaps
1562
+ if order.limit is not None:
1563
+ # Long limit order (size > 0): triggers if open gaps below limit level
1564
+ if order.size > 0 and self.o <= order.limit:
1565
+ return True
1566
+ # Short limit order (size < 0): triggers if open gaps above limit level
1567
+ if order.size < 0 and self.o >= order.limit:
1568
+ return True
1569
+
1570
+ return False
1571
+
1572
+ def _exit_awaits_entry(self, order: Order) -> bool:
1573
+ """True while an exit leg bound to a ``from_entry`` has no open trade to act on.
1574
+
1575
+ TradingView activates a ``strategy.exit`` bracket only after its bound
1576
+ entry fills. Until then (entry pending, cancelled or rejected) the leg
1577
+ must not trigger: a fill would cancel its sibling OCA legs and count
1578
+ toward the filled-order caps even though there is nothing it can close.
1579
+ """
1580
+ if order.order_type != _order_type_close or order.order_id is None or order.from_entry_na:
1581
+ return False
1582
+ return order.order_id not in self._entry_open_ledger
1583
+
1584
+ def _check_high_stop(self, order: Order) -> bool:
1585
+ """ Check high stop and trailing trigger """
1586
+ if order.stop is None:
1587
+ return False
1588
+ if self._exit_awaits_entry(order):
1589
+ return False
1590
+ # Stop order (size > 0) triggers when price rises to stop level
1591
+ if order.size > 0 and order.stop <= self.h:
1592
+ p = max(order.stop, self.o)
1593
+ slippage = lib._script.slippage
1594
+ if slippage > 0:
1595
+ p += syminfo.mintick * slippage
1596
+ order.filled_by_type = 'loss'
1597
+ self.fill_order(order, p, p, self.l)
1598
+ return True
1599
+ return False
1600
+
1601
+ def _check_high(self, order: Order) -> bool:
1602
+ """ Check high limit """
1603
+ if order.limit is not None:
1604
+ if self._exit_awaits_entry(order):
1605
+ return False
1606
+ # Short limit order (size < 0) triggers when price rises to limit level
1607
+ if order.size < 0 and order.limit <= self.h:
1608
+ p = max(order.limit, self.o)
1609
+ order.filled_by_type = 'profit'
1610
+ self.fill_order(order, p, p, self.l)
1611
+ return True
1612
+ return False
1613
+
1614
+ def _check_close_leg_up(self, order: Order) -> bool:
1615
+ """Fill on the closing ascent (low -> close) of the intrabar walk.
1616
+
1617
+ Only an order that became active mid-bar can still be pending here — an
1618
+ exit whose entry filled on an earlier leg. The segment starts at the
1619
+ bar's low, so fills land exactly at the trigger price (no open-gap
1620
+ clamp like :meth:`_check_high` applies).
1621
+ """
1622
+ if self._exit_awaits_entry(order):
1623
+ return False
1624
+ # Short limit (sell back) triggers when price rises to the limit level
1625
+ if order.limit is not None and order.size < 0 and order.limit <= self.c:
1626
+ order.filled_by_type = 'profit'
1627
+ self.fill_order(order, order.limit, order.limit, self.l)
1628
+ return True
1629
+ # Buy stop triggers when price rises to the stop level
1630
+ if order.stop is not None and order.size > 0 and order.stop <= self.c:
1631
+ p = order.stop
1632
+ slippage = lib._script.slippage
1633
+ if slippage > 0:
1634
+ p += syminfo.mintick * slippage
1635
+ order.filled_by_type = 'loss'
1636
+ self.fill_order(order, p, p, self.l)
1637
+ return True
1638
+ return False
1639
+
1640
+ def _process_trailing_stop(self, order: Order, ohlc: bool, close_leg: bool = False) -> int:
1641
+ """Process a trailing-stop exit for the current bar (TradingView model).
1642
+
1643
+ TradingView's broker emulator moves the market price along the assumed
1644
+ intrabar path (``open -> high -> low -> close`` or
1645
+ ``open -> low -> high -> close``, see :meth:`process_orders`) and the
1646
+ trailing stop follows it tick by tick: the high/low-water mark advances
1647
+ on every favorable segment of the path — including the current bar's own
1648
+ extreme — and the stop sits ``trail_offset`` ticks behind it. The trail
1649
+ arms when the path touches ``order.trail_price`` (``entry ±
1650
+ trail_points``) and can fill on the SAME bar once the path retraces
1651
+ ``trail_offset`` ticks from the watermark reached after arming: a bar
1652
+ that pierces the activation level, runs on to its extreme and pulls back
1653
+ fills at ``extreme -/+ offset``, not at the activation level. With
1654
+ ``trail_offset == 0`` the stop sits on the watermark itself, so the fill
1655
+ lands at the activation tick (or at the open of a bar opening beyond the
1656
+ carried watermark).
1657
+
1658
+ A bar that opens beyond a CARRIED stop (inter-bar gap) fills at the
1659
+ open; within the bar the path is assumed gapless, so fills land exactly
1660
+ at the trailed stop level. When the same order also carries a hard
1661
+ ``stop=`` leg that the path reaches earlier in intrabar time — before
1662
+ the trail arms, or at a less favorable level on the same falling
1663
+ segment — the trail defers to the price walk so the hard stop wins.
1664
+ Likewise a take-profit ``limit=`` leg reached on a favorable segment
1665
+ fires before any trailing fill on a later retrace, so the trail defers
1666
+ to the price walk there too (verified against TradingView references
1667
+ on BINANCE:ETHUSDT.P — TV fills the limit at its level, not the
1668
+ trailing stop at ``watermark -/+ offset``); only an offset-0 arming
1669
+ fill at a not-stricter activation level precedes the limit on the same
1670
+ segment.
1671
+
1672
+ The walk is two-phase so it interleaves with the intrabar margin-call
1673
+ checkpoints in :meth:`_process_limit_stop_orders`: the default call
1674
+ handles the open tick and the legs up to the second extreme, persists
1675
+ the armed/water-mark state on the order and reports ``_trail_pending``;
1676
+ a ``close_leg=True`` call resumes from the second extreme and walks the
1677
+ final (extreme -> close) segment. A fill on that closing leg happens
1678
+ chronologically after a margin call at the adverse extreme, which may
1679
+ have already trimmed the position by then.
1680
+
1681
+ :param order: The exit order carrying ``trail_price``.
1682
+ :param ohlc: The bar's intra-bar leg order (see :meth:`process_orders`).
1683
+ :param close_leg: If True, walk only the closing (second extreme ->
1684
+ close) segment, resuming the state a prior default call persisted.
1685
+ :return: ``_trail_filled`` if the order filled, ``_trail_deferred`` if
1686
+ the walk defers to the price walk (or cannot act this bar),
1687
+ ``_trail_pending`` if the closing leg is still outstanding.
1688
+ """
1689
+ if order.trail_price is None:
1690
+ return _trail_deferred
1691
+ if self._exit_awaits_entry(order):
1692
+ return _trail_deferred
1693
+ round_to_mintick = lib.math.round_to_mintick
1694
+ offset_price = syminfo.mintick * order.trail_offset
1695
+ slippage = lib._script.slippage
1696
+
1697
+ if order.sign < 0:
1698
+ # Long position: trailing sell-stop riding under the high-water mark.
1699
+ armed = order.trail_triggered
1700
+ stop = order.trail_stop if armed else None
1701
+
1702
+ if not close_leg and armed and stop is not None:
1703
+ # A carried stop gapped through between bars fills at the open.
1704
+ if self.o <= stop:
1705
+ p = self.o
1706
+ if slippage > 0:
1707
+ p -= syminfo.mintick * slippage
1708
+ order.filled_by_type = 'trailing'
1709
+ self.fill_order(order, p, self.h, p)
1710
+ return _trail_filled
1711
+ # The open tick advances the water mark; with trail_offset == 0
1712
+ # the stop lands on the open itself and fills there.
1713
+ new_stop = round_to_mintick(self.o - offset_price)
1714
+ if new_stop > stop:
1715
+ stop = new_stop
1716
+ if self.o <= stop:
1717
+ p = stop
1718
+ if slippage > 0:
1719
+ p -= syminfo.mintick * slippage
1720
+ order.filled_by_type = 'trailing'
1721
+ self.fill_order(order, p, self.h, p)
1722
+ return _trail_filled
1723
+ elif not close_leg and not armed and self.o >= order.trail_price:
1724
+ # The bar opens beyond the activation level: the trail arms on
1725
+ # the first tick with the open as its water mark.
1726
+ armed = True
1727
+ stop = round_to_mintick(self.o - offset_price)
1728
+ if self.o <= stop:
1729
+ p = stop
1730
+ if slippage > 0:
1731
+ p -= syminfo.mintick * slippage
1732
+ order.filled_by_type = 'trailing'
1733
+ self.fill_order(order, p, self.h, p)
1734
+ return _trail_filled
1735
+
1736
+ # Walk the assumed intrabar path: rising segments arm the trail and
1737
+ # ratchet the water mark, a falling segment fills at the trailed
1738
+ # stop when it reaches it.
1739
+ if close_leg:
1740
+ prev = self.l if ohlc else self.h
1741
+ path: tuple[float, ...] = (self.c,)
1742
+ else:
1743
+ prev = self.o
1744
+ path = (self.h, self.l) if ohlc else (self.l, self.h)
1745
+ for nxt in path:
1746
+ if nxt > prev:
1747
+ if order.limit is not None and nxt >= order.limit and not (
1748
+ not armed and offset_price <= 0
1749
+ and order.trail_price <= order.limit
1750
+ and nxt >= order.trail_price):
1751
+ # The take-profit limit leg is reached on this rising
1752
+ # segment, earlier in intrabar time than any trailing
1753
+ # fill on a later retrace: defer to the price walk so
1754
+ # the limit wins, carrying the trail state ratcheted
1755
+ # so far. Only an offset-0 arming fill at a not-higher
1756
+ # activation level precedes it.
1757
+ order.trail_triggered = armed
1758
+ if armed:
1759
+ order.trail_stop = stop
1760
+ return _trail_deferred
1761
+ if not armed and nxt >= order.trail_price:
1762
+ armed = True
1763
+ stop = round_to_mintick(order.trail_price - offset_price)
1764
+ if order.trail_price <= stop:
1765
+ # trail_offset == 0: the stop sits on the activation
1766
+ # level and the arming tick itself fills it.
1767
+ p = stop
1768
+ if slippage > 0:
1769
+ p -= syminfo.mintick * slippage
1770
+ order.filled_by_type = 'trailing'
1771
+ self.fill_order(order, p, self.h, p)
1772
+ return _trail_filled
1773
+ if armed:
1774
+ new_stop = round_to_mintick(nxt - offset_price)
1775
+ if stop is None or new_stop > stop:
1776
+ stop = new_stop
1777
+ else:
1778
+ if order.limit is not None and prev >= order.limit:
1779
+ # The take-profit limit became marketable earlier on
1780
+ # the path (at the open tick or on a prior rising
1781
+ # segment): defer to the price walk so the limit wins.
1782
+ order.trail_triggered = armed
1783
+ if armed:
1784
+ order.trail_stop = stop
1785
+ return _trail_deferred
1786
+ if order.stop is not None and nxt <= order.stop and (
1787
+ not armed or stop is None or order.stop >= stop):
1788
+ # The hard stop leg is reached earlier in intrabar time:
1789
+ # defer to the price walk, carrying the trail state
1790
+ # ratcheted so far.
1791
+ order.trail_triggered = armed
1792
+ if armed:
1793
+ order.trail_stop = stop
1794
+ return _trail_deferred
1795
+ if armed and stop is not None and nxt <= stop:
1796
+ p = stop
1797
+ if slippage > 0:
1798
+ p -= syminfo.mintick * slippage
1799
+ order.filled_by_type = 'trailing'
1800
+ self.fill_order(order, p, self.h, p)
1801
+ return _trail_filled
1802
+ prev = nxt
1803
+
1804
+ # No fill: persist the ratcheted state — the default call hands it
1805
+ # to the closing-leg call, which in turn carries it into the next bar.
1806
+ if armed:
1807
+ order.trail_triggered = True
1808
+ order.trail_stop = stop
1809
+ return _trail_pending
1810
+
1811
+ if order.sign > 0:
1812
+ # Short position: trailing buy-stop riding above the low-water mark.
1813
+ armed = order.trail_triggered
1814
+ stop = order.trail_stop if armed else None
1815
+
1816
+ if not close_leg and armed and stop is not None:
1817
+ # A carried stop gapped through between bars fills at the open.
1818
+ if self.o >= stop:
1819
+ p = self.o
1820
+ if slippage > 0:
1821
+ p += syminfo.mintick * slippage
1822
+ order.filled_by_type = 'trailing'
1823
+ self.fill_order(order, p, p, self.l)
1824
+ return _trail_filled
1825
+ # The open tick advances the water mark; with trail_offset == 0
1826
+ # the stop lands on the open itself and fills there.
1827
+ new_stop = round_to_mintick(self.o + offset_price)
1828
+ if new_stop < stop:
1829
+ stop = new_stop
1830
+ if self.o >= stop:
1831
+ p = stop
1832
+ if slippage > 0:
1833
+ p += syminfo.mintick * slippage
1834
+ order.filled_by_type = 'trailing'
1835
+ self.fill_order(order, p, p, self.l)
1836
+ return _trail_filled
1837
+ elif not close_leg and not armed and self.o <= order.trail_price:
1838
+ # The bar opens beyond the activation level: the trail arms on
1839
+ # the first tick with the open as its water mark.
1840
+ armed = True
1841
+ stop = round_to_mintick(self.o + offset_price)
1842
+ if self.o >= stop:
1843
+ p = stop
1844
+ if slippage > 0:
1845
+ p += syminfo.mintick * slippage
1846
+ order.filled_by_type = 'trailing'
1847
+ self.fill_order(order, p, p, self.l)
1848
+ return _trail_filled
1849
+
1850
+ # Walk the assumed intrabar path: falling segments arm the trail and
1851
+ # ratchet the water mark, a rising segment fills at the trailed stop
1852
+ # when it reaches it.
1853
+ if close_leg:
1854
+ prev = self.l if ohlc else self.h
1855
+ path = (self.c,)
1856
+ else:
1857
+ prev = self.o
1858
+ path = (self.h, self.l) if ohlc else (self.l, self.h)
1859
+ for nxt in path:
1860
+ if nxt < prev:
1861
+ if order.limit is not None and nxt <= order.limit and not (
1862
+ not armed and offset_price <= 0
1863
+ and order.trail_price >= order.limit
1864
+ and nxt <= order.trail_price):
1865
+ # The take-profit limit leg is reached on this falling
1866
+ # segment, earlier in intrabar time than any trailing
1867
+ # fill on a later rebound: defer to the price walk so
1868
+ # the limit wins, carrying the trail state ratcheted
1869
+ # so far. Only an offset-0 arming fill at a not-lower
1870
+ # activation level precedes it.
1871
+ order.trail_triggered = armed
1872
+ if armed:
1873
+ order.trail_stop = stop
1874
+ return _trail_deferred
1875
+ if not armed and nxt <= order.trail_price:
1876
+ armed = True
1877
+ stop = round_to_mintick(order.trail_price + offset_price)
1878
+ if order.trail_price >= stop:
1879
+ # trail_offset == 0: the stop sits on the activation
1880
+ # level and the arming tick itself fills it.
1881
+ p = stop
1882
+ if slippage > 0:
1883
+ p += syminfo.mintick * slippage
1884
+ order.filled_by_type = 'trailing'
1885
+ self.fill_order(order, p, p, self.l)
1886
+ return _trail_filled
1887
+ if armed:
1888
+ new_stop = round_to_mintick(nxt + offset_price)
1889
+ if stop is None or new_stop < stop:
1890
+ stop = new_stop
1891
+ else:
1892
+ if order.limit is not None and prev <= order.limit:
1893
+ # The take-profit limit became marketable earlier on
1894
+ # the path (at the open tick or on a prior falling
1895
+ # segment): defer to the price walk so the limit wins.
1896
+ order.trail_triggered = armed
1897
+ if armed:
1898
+ order.trail_stop = stop
1899
+ return _trail_deferred
1900
+ if order.stop is not None and nxt >= order.stop and (
1901
+ not armed or stop is None or order.stop <= stop):
1902
+ # The hard stop leg is reached earlier in intrabar time:
1903
+ # defer to the price walk, carrying the trail state
1904
+ # ratcheted so far.
1905
+ order.trail_triggered = armed
1906
+ if armed:
1907
+ order.trail_stop = stop
1908
+ return _trail_deferred
1909
+ if armed and stop is not None and nxt >= stop:
1910
+ p = stop
1911
+ if slippage > 0:
1912
+ p += syminfo.mintick * slippage
1913
+ order.filled_by_type = 'trailing'
1914
+ self.fill_order(order, p, p, self.l)
1915
+ return _trail_filled
1916
+ prev = nxt
1917
+
1918
+ # No fill: persist the ratcheted state — the default call hands it
1919
+ # to the closing-leg call, which in turn carries it into the next bar.
1920
+ if armed:
1921
+ order.trail_triggered = True
1922
+ order.trail_stop = stop
1923
+ return _trail_pending
1924
+
1925
+ return _trail_deferred
1926
+
1927
+ def _seed_trail_at_issue(self, order: Order, *, fold_extreme: bool = True) -> None:
1928
+ """Fold the issue bar into a trailing exit's high/low-water mark.
1929
+
1930
+ ``process_orders`` runs before the script body, so an exit issued in the
1931
+ script on bar N -- e.g. one gated on ``strategy.position_size``, which is
1932
+ only known once the entry has filled -- is first evaluated on bar N+1.
1933
+ The entry-fill bar's own extreme would then never seed the trail, leaving
1934
+ PyneCore's water mark one bar behind TradingView's, which keeps the
1935
+ trailing stop alive from the bar the position is already open. Advance the
1936
+ water mark here at issue time (activation + ratchet only -- the fill still
1937
+ happens in the next ``process_orders``).
1938
+
1939
+ Exits placed on the entry SIGNAL bar (entry still pending, so no bound
1940
+ trade is open yet) are skipped: ``process_orders`` seeds those on their
1941
+ fill bar exactly as before, so the single-issue path is unchanged.
1942
+
1943
+ With ``fold_extreme=False`` (a changed-params re-issue) the water mark
1944
+ anchors to the issue bar's CLOSE tick instead of its extreme: the
1945
+ replaced leg sees only the current price, so it arms there when the
1946
+ activation is already met, and the next bar's open advances the stop
1947
+ only when favorable. TV-verified both ways on BINANCE:BTCUSDT 30m
1948
+ (per-bar ``atr*mult`` trail): a long re-issue filled at
1949
+ ``next open - offset`` (open above close, mark advanced) and a short
1950
+ re-issue filled at ``close + offset`` (open above close, mark kept).
1951
+
1952
+ :param order: The freshly (re-)issued trailing exit order.
1953
+ :param fold_extreme: If True, ratchet the issue bar's H/L extreme into
1954
+ the water mark; if False, anchor the water mark to the bar close.
1955
+ """
1956
+ if order.trail_points_ticks is None and order.trail_price is None:
1957
+ return
1958
+ entry_price: float | None = None
1959
+ for trade in self.open_trades:
1960
+ if trade.entry_id == order.order_id:
1961
+ entry_price = trade.entry_price
1962
+ break
1963
+ if entry_price is None:
1964
+ return # entry still pending -- seeded later on the fill bar
1965
+
1966
+ direction = 1.0 if order.size < 0 else -1.0
1967
+ trail_price = order.trail_price
1968
+ if trail_price is None and order.trail_points_ticks is not None:
1969
+ trail_price = _price_round(
1970
+ entry_price + direction * syminfo.mintick * order.trail_points_ticks, direction)
1971
+ if trail_price is None:
1972
+ return
1973
+
1974
+ round_to_mintick = lib.math.round_to_mintick
1975
+ offset_price = syminfo.mintick * order.trail_offset
1976
+ # Arming on the issue (entry-fill) bar is gated on the bar CLOSE, not its
1977
+ # intrabar extreme: TradingView only carries a trailing stop out of the
1978
+ # entry-fill bar when that bar closes past the activation level. A bar
1979
+ # whose extreme pierces the activation level but closes back inside it does
1980
+ # NOT arm here -- it arms later, intrabar, in the normal price walk (which
1981
+ # also performs the same-bar fill that is suppressed on the entry-fill
1982
+ # bar). On every later bar a close past the level implies the high already
1983
+ # pierced it, so process_orders has already armed the carried order and
1984
+ # this gate never fires there.
1985
+ if order.sign < 0:
1986
+ # Long position: trailing sell-stop riding under the high-water mark.
1987
+ if not order.trail_triggered:
1988
+ if self.c <= trail_price:
1989
+ return
1990
+ order.trail_triggered = True
1991
+ order.trail_stop = round_to_mintick(trail_price - offset_price)
1992
+ new_stop = round_to_mintick((self.h if fold_extreme else self.c) - offset_price)
1993
+ if order.trail_stop is None or new_stop > order.trail_stop:
1994
+ order.trail_stop = new_stop
1995
+ elif order.sign > 0:
1996
+ # Short position: trailing buy-stop riding above the low-water mark.
1997
+ if not order.trail_triggered:
1998
+ if self.c >= trail_price:
1999
+ return
2000
+ order.trail_triggered = True
2001
+ order.trail_stop = round_to_mintick(trail_price + offset_price)
2002
+ new_stop = round_to_mintick((self.l if fold_extreme else self.c) + offset_price)
2003
+ if order.trail_stop is None or new_stop < order.trail_stop:
2004
+ order.trail_stop = new_stop
2005
+
2006
+ def _check_margin_call(self, check_price: float, *, for_short: bool,
2007
+ at_open: bool = False,
2008
+ can_defer: bool = True,
2009
+ whole_contracts: bool = False) -> bool:
2010
+ """
2011
+ Check and execute margin call using TradingView's 10-step algorithm.
2012
+
2013
+ TradingView's 3-branch margin call logic:
2014
+ 1. AF@O < 0: fire immediately at open price (at_open=True)
2015
+ 2. mc_size > 1: fire immediately at worst-case price (H for shorts, L for longs)
2016
+ 3. mc_size == 1 AND can_defer AND AF@C < 0: defer MC to post-script at close price
2017
+ 4. mc_size == 1 AND (not can_defer OR AF@C >= 0): fire immediately at worst-case
2018
+
2019
+ Deferral is only allowed at the first OHLC extremum (where recovery is still
2020
+ possible at the opposite extremum). At the second extremum only close remains,
2021
+ so TV fires immediately.
2022
+
2023
+ :param check_price: The price to check margin at
2024
+ :param for_short: If True, check short positions. If False, check long positions.
2025
+ :param at_open: If True, this is an open check — always fire immediately, never defer.
2026
+ :param can_defer: If False, MC fires immediately even when mc_size==1 and AF@C<0.
2027
+ :param whole_contracts: If True, size the liquidation in whole contracts even on
2028
+ fractional-lot symbols. TV's bar-open margin call (the one that fires right
2029
+ after entry fills at the open price) liquidates whole contracts, while its
2030
+ intrabar (H/L) and deferred margin calls work in lot units.
2031
+ :return: True if MC was deferred (caller should stop OHLC processing)
2032
+ """
2033
+ if not self.open_trades:
2034
+ return False
2035
+
2036
+ if for_short and self.sign >= 0:
2037
+ return False
2038
+ if not for_short and self.sign <= 0:
2039
+ return False
2040
+
2041
+ script = lib._script
2042
+ margin_percent = script.margin_short if for_short else script.margin_long
2043
+
2044
+ if margin_percent <= 0:
2045
+ return False
2046
+
2047
+ quantity = abs(self.size)
2048
+ # Convert price * quantity to account-currency for margin/equity comparisons.
2049
+ pv = syminfo.pointvalue
2050
+
2051
+ money_spent = quantity * self.avg_price * pv
2052
+ mvs = quantity * check_price * pv
2053
+
2054
+ open_profit = mvs - money_spent
2055
+ if self.sign < 0:
2056
+ open_profit = -open_profit
2057
+
2058
+ equity = script.initial_capital + self.netprofit + open_profit
2059
+ margin_ratio = margin_percent / 100.0
2060
+ margin = mvs * margin_ratio
2061
+ available_funds = equity - margin
2062
+
2063
+ # From 1e7 account-currency units of equity upward the margin-call
2064
+ # trigger is an integer-tick comparison on the STRICT side: it fires
2065
+ # once the truncated equity tick-count no longer covers the required
2066
+ # margin rounded half-up to a tick, even while the float difference
2067
+ # is still a positive surplus. Measured on BINANCE:BTCUSDT 30m,
2068
+ # Hybrid 2025-10-02 16:00: available funds +0.0047 USD at every bar
2069
+ # price, yet TV liquidated one whole contract at H=120300 — exactly
2070
+ # the first walk point where this comparison fails (open and low
2071
+ # both pass it). From 1e10 margin ticks upward the margin rounds to
2072
+ # the nearest multiple of 10 ticks instead (Hybrid 2026-02-28 20:30:
2073
+ # available funds +0.0132 USD at the bar low, yet TV liquidated one
2074
+ # whole contract — the margin rounded up to the next multiple of 10
2075
+ # ticks while the equity truncated 4 ticks below it; the open and
2076
+ # high of the same bar stayed on grid and passed).
2077
+ mintick = syminfo.mintick
2078
+ big_equity = equity >= 1e7 and mintick and mintick > 0
2079
+ big_margin = False
2080
+ equity_ticks = 0.0
2081
+ margin_ticks = 0.0
2082
+ if big_equity:
2083
+ equity_ticks = math.floor(equity / mintick)
2084
+ margin_ticks = margin / mintick
2085
+ big_margin = margin_ticks >= 1e10
2086
+ if big_margin:
2087
+ margin_ticks = 10.0 * round(margin_ticks / 10.0)
2088
+ else:
2089
+ margin_ticks = math.floor(margin_ticks + 0.5)
2090
+ if equity_ticks >= margin_ticks:
2091
+ return False
2092
+ elif available_funds >= 0:
2093
+ return False
2094
+
2095
+ # One contract is worth `check_price * pv` in account currency. Work in
2096
+ # lot units (1 / _size_round_factor): whole-lot symbols (stocks) keep
2097
+ # TV's integer-contract truncation, while fractional-lot symbols
2098
+ # (crypto) liquidate fractional amounts the way TV does instead of
2099
+ # force-closing a minimum of one whole contract.
2100
+ rfactor = 1 if whole_contracts else syminfo._size_round_factor # noqa
2101
+ if big_margin:
2102
+ # Above 1e10 margin ticks the cover comes from the same tick-shadow
2103
+ # shortfall as the trigger, then a plain truncation with no float
2104
+ # snap (Hybrid 2026-02-16 15:30 and 2026-02-20 13:30 both round the
2105
+ # margin up to an odd tick-count that a half-up or half-to-even
2106
+ # rounding would keep down).
2107
+ shortfall = (margin_ticks - equity_ticks) * mintick
2108
+ loss = shortfall / margin_ratio
2109
+ cover_lots = int(loss / (check_price * pv) * rfactor)
2110
+ if cover_lots < 0:
2111
+ cover_lots = 0
2112
+ else:
2113
+ loss = available_funds / margin_ratio
2114
+ raw_cover_lots = abs(loss) / (check_price * pv) * rfactor
2115
+ # TV truncates the fractional cover amount, but snaps a raw value
2116
+ # that lands within ~2^-26 (relative) of an integer to that
2117
+ # integer. Measured on BINANCE:BTCUSDT 30m corpus margin calls:
2118
+ # 21840.99976 (rel dist 1.10e-8) covered 21841 lots on TV, while
2119
+ # 26510.99945 (rel dist 2.08e-8) truncated to 26510; 2^-26 =
2120
+ # 1.49e-8 lies between them.
2121
+ nearest_cover = round(raw_cover_lots)
2122
+ if abs(raw_cover_lots - nearest_cover) <= raw_cover_lots * 2.0 ** -26 + 1e-9:
2123
+ cover_lots = nearest_cover
2124
+ else:
2125
+ cover_lots = int(raw_cover_lots)
2126
+ if cover_lots == 0 and rfactor > 1:
2127
+ # Fractional-lot symbol with a sub-lot shortfall: TradingView closes
2128
+ # one whole contract, capped by the current position size. This holds
2129
+ # at the open AND intrabar, regardless of position size
2130
+ # (BINANCE:BTCUSDT 30m Gaussian Channel corpus: 43 margin calls that
2131
+ # trim exactly 1.0 from 8+ contract positions — longs at the
2132
+ # entry-fill open price, shorts at a high one tick above the open).
2133
+ mc_lots = 0
2134
+ margin_call_size = min(1.0, quantity)
2135
+ else:
2136
+ mc_lots = max(1, cover_lots * 4)
2137
+ margin_call_size = mc_lots / rfactor
2138
+
2139
+ if margin_call_size > quantity:
2140
+ margin_call_size = quantity
2141
+
2142
+ # Deferral check: mc_size==1 lot at first OHLC extremum, check if AF@C<0
2143
+ # Skip deferral when check_price == close: no recovery possible at same price
2144
+ if not at_open and can_defer and mc_lots == 1 and check_price != self.c:
2145
+ c_mvs = quantity * self.c * pv
2146
+ c_open_profit = c_mvs - money_spent
2147
+ if self.sign < 0:
2148
+ c_open_profit = -c_open_profit
2149
+ c_equity = script.initial_capital + self.netprofit + c_open_profit
2150
+ c_margin = c_mvs * margin_ratio
2151
+ c_af = c_equity - c_margin
2152
+ if c_af < 0:
2153
+ self._deferred_margin_call = (self.c, for_short)
2154
+ return True
2155
+
2156
+ fill_price = check_price
2157
+ if script.slippage > 0:
2158
+ slippage_amount = syminfo.mintick * script.slippage
2159
+ if for_short:
2160
+ fill_price = check_price + slippage_amount
2161
+ else:
2162
+ fill_price = check_price - slippage_amount
2163
+
2164
+ margin_call_order = Order(
2165
+ None,
2166
+ -self.sign * margin_call_size,
2167
+ order_type=_order_type_close,
2168
+ comment='Margin call'
2169
+ )
2170
+ margin_call_order.is_market_order = False
2171
+ margin_call_order.bar_index = int(lib.bar_index)
2172
+
2173
+ self._fill_order(margin_call_order, fill_price, fill_price, fill_price)
2174
+ return False
2175
+
2176
+ def process_deferred_margin_call(self):
2177
+ """
2178
+ Execute a deferred margin call (after the user script has run), then
2179
+ re-check margin at the bar close the way TradingView does.
2180
+ Called from script_runner after the user script's main() completes.
2181
+
2182
+ TV evaluates margin at every bar close and books the liquidation on
2183
+ that bar at the close price; without this check the same liquidation
2184
+ only fires at the next bar's open — one bar late, and at the open
2185
+ price on gapped data (Hybrid 2026-05-07 02:00: TV trims 1.0 contract
2186
+ at C=80898.0 on the 02:00 bar while the O/H/L walk points all pass
2187
+ the margin comparison). Sized like the bar-open check in whole
2188
+ contracts; every observed instance trimmed exactly 1.0 contract, so
2189
+ the whole-contract choice is untested beyond that.
2190
+ """
2191
+ prev_count = len(self.new_closed_trades)
2192
+
2193
+ if self._deferred_margin_call is not None:
2194
+ check_price, for_short = self._deferred_margin_call
2195
+ self._deferred_margin_call = None
2196
+ self._check_margin_call(check_price, for_short=for_short, at_open=True)
2197
+
2198
+ if self.open_trades:
2199
+ self._check_margin_call(self.c, for_short=self.sign < 0, at_open=True,
2200
+ whole_contracts=True)
2201
+
2202
+ initial_capital = lib._script.initial_capital
2203
+ for closed_trade in self.new_closed_trades[prev_count:]:
2204
+ self.cum_profit += closed_trade.profit
2205
+ closed_trade.cum_profit = self.cum_profit
2206
+ try:
2207
+ closed_trade.cum_profit_percent = (
2208
+ closed_trade.cum_profit / initial_capital) * 100.0
2209
+ except ZeroDivisionError:
2210
+ closed_trade.cum_profit_percent = 0.0
2211
+ self.entry_equity += closed_trade.profit
2212
+
2213
+ def _resolve_deferred_qty(self, order: Order, fill_price: float) -> None:
2214
+ """Finalize a default-sized entry's quantity at its actual fill price.
2215
+
2216
+ TradingView resolves percent_of_equity / cash default sizing of
2217
+ price-based (limit/stop) orders when the order EXECUTES: the
2218
+ investment target is divided by the real fill price, with equity
2219
+ measured at that moment. For those the placement-time size was only
2220
+ the margin-check estimate — a marketable limit filling at the open
2221
+ re-sizes here. Market entries never defer: they keep the
2222
+ placement-close size computed in ``entry`` (TV-probe-verified). The
2223
+ reversal flip component stays frozen from creation (TV computes the
2224
+ flip quantity at order creation time).
2225
+ """
2226
+ order.deferred_qty = False
2227
+ old_abs = abs(order.size)
2228
+ qty = _default_entry_qty(float(fill_price))
2229
+ if qty <= 0.0:
2230
+ order.size = 0.0
2231
+ return
2232
+ size = _size_round((qty + order.flip_extra) * order.sign)
2233
+ if size != 0.0:
2234
+ # The big-money sizing judgment applies to the money-sized part of
2235
+ # the order only; the reversal flip component is the old position,
2236
+ # already an exact lot multiple.
2237
+ flip = order.flip_extra * order.sign
2238
+ size = _judge_money_entry(size - flip, float(fill_price)) + flip
2239
+ order.size = size
2240
+ # A default-sized entry that resolves LARGER than its placement estimate
2241
+ # would strand a sliver: the bracket's no-qty "rest" leg reserved off the
2242
+ # smaller estimate and would under-close the fill. Grow those legs by the
2243
+ # extra so they still cover the whole entry, matching TradingView (which
2244
+ # sizes the entry at fill and closes all of it). A smaller resolution
2245
+ # never strands — the over-reservation is clamped by the FIFO close.
2246
+ extra = abs(order.size) - old_abs
2247
+ if extra > 0.0 and order.order_id is not None:
2248
+ self._grow_rest_exit_legs(order.order_id, extra)
2249
+
2250
+ def _grow_rest_exit_legs(self, entry_id: str, extra: float) -> None:
2251
+ """Extend an entry's full-close bracket legs by ``extra`` contracts.
2252
+
2253
+ Only ``rest_leg`` exits (no explicit qty / qty_percent — the "close the
2254
+ whole entry" leg) grow; an absolute-qty or qty_percent leg keeps the
2255
+ slice it was given. A grown reservation is clamped by the FIFO close to
2256
+ the actually open size, so over-reserving is safe.
2257
+ """
2258
+ for o in self.exit_orders.values():
2259
+ if (o.rest_leg and o.order_id == entry_id
2260
+ and not o.consumed and o.book_seq is None and o.size != 0.0):
2261
+ grown = _size_round(o.reserved_size + extra)
2262
+ o.reserved_size = grown
2263
+ o.size = math.copysign(grown, o.size)
2264
+
2265
+ def _cancel_unaffordable_entries(self) -> None:
2266
+ """
2267
+ Cancel pending price-based entry orders the account can no longer margin.
2268
+
2269
+ TradingView re-evaluates an unfilled entry order's required margin at the
2270
+ CURRENT price (the "LastPrice" of its margin formula), cancelling the order
2271
+ once the requirement exceeds equity. The sweep runs after the bar's fill
2272
+ phases: a marketable order fills at the open before any check can touch it,
2273
+ and a resting order gets this bar's fill window first. At 100%
2274
+ percent_of_equity sizing this kills every resting buy limit below the
2275
+ market (required = equity * price / limit > equity) while a resting sell
2276
+ limit above the market survives (required < equity) -- exactly the
2277
+ asymmetry TradingView's exported trade lists show.
2278
+ """
2279
+ if not self.entry_orders:
2280
+ return
2281
+ script = lib._script
2282
+ pv = syminfo.pointvalue
2283
+ for order in list(self.entry_orders.values()):
2284
+ if order.order_type != _order_type_entry:
2285
+ continue
2286
+ if order.limit is None and order.stop is None:
2287
+ continue
2288
+ margin_percent = script.margin_short if order.sign < 0 else script.margin_long
2289
+ if margin_percent <= 0:
2290
+ continue
2291
+ resulting_qty = abs(self.size + order.size)
2292
+ margin_needed = resulting_qty * self.c * pv * (margin_percent / 100.0)
2293
+ if margin_needed > self.equity:
2294
+ self._remove_order(order)
2295
+
2296
+ def _entry_exceeds_margin_after_fill(self, order: Order, fill_price: float,
2297
+ base_size: float | None = None,
2298
+ base_equity: float | None = None) -> bool:
2299
+ """
2300
+ Check whether an entry's resulting position is affordable at its fill price.
2301
+
2302
+ TV rejects the entry before filling when the position that would remain after
2303
+ the fill cannot be margined. Once an entry has filled, later open/high/low
2304
+ margin breaches are handled by the margin-call path.
2305
+
2306
+ ``base_size``/``base_equity`` override the position size the fill adds to and
2307
+ the equity it is margined against (both default to the current values).
2308
+ Passing the bar-start size AND equity tests whether the order would have been
2309
+ affordable on its own — an over-margin caused only by a prior same-bar fill
2310
+ (which also shifts ``self.equity`` via its open P&L) is handled by the
2311
+ margin-call path, not a hard reject.
2312
+ """
2313
+ script = lib._script
2314
+ margin_percent = script.margin_short if order.sign < 0 else script.margin_long
2315
+ if margin_percent <= 0:
2316
+ return False
2317
+
2318
+ pv = syminfo.pointvalue
2319
+ margin_ratio = margin_percent / 100.0
2320
+
2321
+ if base_size is None:
2322
+ base_size = self.size
2323
+ new_qty = abs(base_size + order.size)
2324
+ if new_qty == 0.0:
2325
+ return False
2326
+
2327
+ equity = self.equity if base_equity is None else base_equity
2328
+ margin_needed = new_qty * fill_price * pv * margin_ratio
2329
+ # From 1e7 account-currency units of equity upward TV decides the fill
2330
+ # with an integer-tick comparison on the PERMISSIVE side: the entry
2331
+ # fills while the equity rounded half-up to a tick still covers the
2332
+ # truncated tick-count of the required margin — a sub-tick shortfall
2333
+ # fills and the bar-open margin-call path then trims the position (a
2334
+ # sub-lot shortfall liquidates one whole contract). Measured on
2335
+ # BINANCE:BTCUSDT 30m: Hybrid 2025-06-12 22:30 (shortfall 0.0076 USD,
2336
+ # 0.76 tick) FILLED + 1-contract MC at the open, while one-shot
2337
+ # initial_capital replicas 1.00 and 1.81 ticks short both REJECTED.
2338
+ # Below the 1e7 gate TV rejects on a strict "margin exceeds equity":
2339
+ # a percent_of_equity entry sized at the signal close fills at the next
2340
+ # open, so a positive shortfall means the fill price rose above the
2341
+ # sizing price and the position no longer fits — TV rejects it (there is
2342
+ # no legitimate positive-shortfall fill; only the fill-price move can
2343
+ # create one). The tolerance is float noise only. Measured on
2344
+ # BINANCE:BTCUSDT 30m: Master Trend 2025-04-17 05:00 rejected at a
2345
+ # +0.00045 USD / 4.7e-10 relative shortfall (a 1-tick fill-open move
2346
+ # eating the mincontract rounding buffer), tighter than the earlier
2347
+ # corpus rejects at 1.75e-9..1.06e-7; the accumulated netprofit float
2348
+ # error over a full run stays ~1e-13 relative, so 1e-11 separates real
2349
+ # overages from noise.
2350
+ mintick = syminfo.mintick
2351
+ if equity >= 1e7 and mintick and mintick > 0:
2352
+ return math.floor(equity / mintick + 0.5) < math.floor(margin_needed / mintick)
2353
+ return margin_needed - equity > abs(equity) * 1e-11
2354
+
2355
+ def _cancel_same_bar_reversal_closes(self, entry_order: Order) -> None:
2356
+ """
2357
+ Cancel market closes made redundant by a same-bar opposite entry.
2358
+
2359
+ A reversing ``strategy.entry`` is itself the close request for the current
2360
+ position. If that entry is rejected at its fill, TV does not then fill a
2361
+ same-bar ``strategy.close`` for the old position as a fallback.
2362
+ """
2363
+ if self.size == 0.0 or self.sign == entry_order.sign:
2364
+ return
2365
+
2366
+ open_entry_ids = {trade.entry_id for trade in self.open_trades}
2367
+ for close_order in list(self.market_orders.values()):
2368
+ if close_order.order_type != _order_type_close:
2369
+ continue
2370
+ if close_order.bar_index != entry_order.bar_index:
2371
+ continue
2372
+ if close_order.sign != entry_order.sign:
2373
+ continue
2374
+ if close_order.order_id is None or close_order.order_id in open_entry_ids:
2375
+ self._remove_order(close_order)
2376
+
2377
+ def _check_low_stop(self, order: Order) -> bool:
2378
+ """ Check low stop """
2379
+ if order.stop is None:
2380
+ return False
2381
+ if self._exit_awaits_entry(order):
2382
+ return False
2383
+ # Stop order (size < 0) triggers when price falls to stop level
2384
+ if order.size < 0 and order.stop >= self.l:
2385
+ p = min(self.o, order.stop)
2386
+ slippage = lib._script.slippage
2387
+ if slippage > 0:
2388
+ p -= syminfo.mintick * slippage
2389
+ order.filled_by_type = 'loss'
2390
+ self.fill_order(order, p, self.h, p)
2391
+ return True
2392
+ return False
2393
+
2394
+ def _check_low(self, order: Order) -> bool:
2395
+ """ Check low limit """
2396
+ if order.limit is not None:
2397
+ if self._exit_awaits_entry(order):
2398
+ return False
2399
+ # Long limit order (size > 0) triggers when price falls to limit level
2400
+ if order.size > 0 and order.limit >= self.l:
2401
+ p = min(self.o, order.limit)
2402
+ order.filled_by_type = 'profit'
2403
+ self.fill_order(order, p, self.h, p)
2404
+ return True
2405
+ return False
2406
+
2407
+ def _check_close_leg_down(self, order: Order) -> bool:
2408
+ """Fill on the closing descent (high -> close) of the intrabar walk.
2409
+
2410
+ Only an order that became active mid-bar can still be pending here — an
2411
+ exit whose entry filled on an earlier leg. The segment starts at the
2412
+ bar's high, so fills land exactly at the trigger price (no open-gap
2413
+ clamp like :meth:`_check_low` applies).
2414
+ """
2415
+ if self._exit_awaits_entry(order):
2416
+ return False
2417
+ # Long limit (buy back) triggers when price falls to the limit level
2418
+ if order.limit is not None and order.size > 0 and order.limit >= self.c:
2419
+ order.filled_by_type = 'profit'
2420
+ self.fill_order(order, order.limit, self.h, order.limit)
2421
+ return True
2422
+ # Sell stop triggers when price falls to the stop level
2423
+ if order.stop is not None and order.size < 0 and order.stop >= self.c:
2424
+ p = order.stop
2425
+ slippage = lib._script.slippage
2426
+ if slippage > 0:
2427
+ p -= syminfo.mintick * slippage
2428
+ order.filled_by_type = 'loss'
2429
+ self.fill_order(order, p, self.h, p)
2430
+ return True
2431
+ return False
2432
+
2433
+ def process_orders(self):
2434
+ """ Process orders """
2435
+ # We need to round to the nearest tick to get the same results as in TradingView.
2436
+ # ``lib.math.round_to_mintick`` is inlined here (this preamble runs every bar):
2437
+ # OHLC are always plain floats at this point, so its NA branch is dead code.
2438
+ # The expression shape must stay ``int(x / mintick + 0.5) * minmove / pricescale``
2439
+ # (left to right) — see the bit-parity note in ``lib/math.py``.
2440
+ mintick = syminfo.mintick
2441
+ minmove = syminfo.minmove
2442
+ pricescale = syminfo.pricescale
2443
+ self.o = int(lib.open / mintick + 0.5) * minmove / pricescale
2444
+ self.h = int(lib.high / mintick + 0.5) * minmove / pricescale
2445
+ self.l = int(lib.low / mintick + 0.5) * minmove / pricescale
2446
+ self.c = int(lib.close / mintick + 0.5) * minmove / pricescale
2447
+
2448
+ self.drawdown_summ = self.runup_summ = 0.0
2449
+ self.new_closed_trades.clear()
2450
+ # Undo any immediate close a COOF trial body run enqueued (position-side
2451
+ # analog of the restored ``var`` state); no-op in the common case.
2452
+ self._discard_deferred_immediate_closes()
2453
+
2454
+ # Idle fast path: with no open position and no pending orders every phase
2455
+ # below is a provable no-op (each loop iterates an empty container, every
2456
+ # ``_check_margin_call`` early-returns on ``not open_trades``) except the
2457
+ # trading-day rollover and the post-bar risk rules — run just those two.
2458
+ if (not self.open_trades and not self.entry_orders and not self.exit_orders
2459
+ and not self.market_orders and not self.orderbook.price_levels):
2460
+ if self._roll_trading_day():
2461
+ return
2462
+ if (self.risk_max_drawdown_value is not None
2463
+ or self.risk_max_intraday_loss_value is not None
2464
+ or self.risk_max_cons_loss_days is not None):
2465
+ self._enforce_post_bar_risk()
2466
+ return
2467
+
2468
+ # If the order is open → high → low → close or open → low → high → close
2469
+ ohlc = self.h - self.o < self.o - self.l
2470
+
2471
+ self._process_at_bar_open(ohlc)
2472
+ self._process_limit_stop_orders(ohlc)
2473
+ self._cancel_unaffordable_entries()
2474
+ self._finalize_bar_pnl()
2475
+ if (self.risk_max_drawdown_value is not None
2476
+ or self.risk_max_intraday_loss_value is not None
2477
+ or self.risk_max_cons_loss_days is not None):
2478
+ self._enforce_post_bar_risk()
2479
+ self._finalize_new_closed_trades()
2480
+
2481
+ def _roll_trading_day(self) -> bool:
2482
+ """Roll the intraday risk anchors when the bar enters a new trading day.
2483
+
2484
+ ``time_tradingday`` is session-aware: for overnight sessions (forex,
2485
+ futures) the day rolls at the session open (e.g. 17:00 ET), not at
2486
+ calendar midnight — matching TradingView's intraday risk reset. For
2487
+ 24/7 crypto and intraday stock sessions it collapses to the calendar
2488
+ day in the exchange timezone, so those symbols are unaffected.
2489
+
2490
+ :return: True when the ``max_cons_loss_days`` halt fired — the caller
2491
+ must stop processing the bar's orders.
2492
+ """
2493
+ # Statically a value (module_property), at runtime still the function
2494
+ current_trading_day = int(lib.time_tradingday())
2495
+ if current_trading_day == self.risk_last_trading_day:
2496
+ return False
2497
+ current_equity = float(self.equity)
2498
+ # Roll over consecutive-loss-day count for ``strategy.risk.max_cons_loss_days``.
2499
+ # On the very first bar we have no prior day to compare against — initialise
2500
+ # the trailing-equity anchor without touching the loss-day counter.
2501
+ if self.risk_last_trading_day != -1:
2502
+ if current_equity < self.risk_last_day_equity:
2503
+ self.risk_cons_loss_days += 1
2504
+ else:
2505
+ self.risk_cons_loss_days = 0
2506
+ self.risk_last_day_equity = current_equity
2507
+ # Anchor for ``strategy.risk.max_intraday_loss`` — captured at the
2508
+ # start of every trading day, not just the first one.
2509
+ self.risk_intraday_start_equity = current_equity
2510
+ self.risk_last_trading_day = current_trading_day
2511
+ self.risk_intraday_filled_orders = 0
2512
+ # ``max_cons_loss_days`` becomes known the moment the day rolls
2513
+ # over — halt now rather than at bar end so the new day's queued
2514
+ # entries cannot fill at this bar's open.
2515
+ if self._is_max_cons_loss_days_breached() and not self.risk_halt_trading:
2516
+ self._trigger_risk_halt(
2517
+ "Max consecutive loss days reached", self.o, self.h, self.l,
2518
+ )
2519
+ return True
2520
+ return False
2521
+
2522
+ def _process_at_bar_open(self, ohlc: bool):
2523
+ """Phase 1: Process orders at bar open — gap detection, market fills, margin."""
2524
+ if self._roll_trading_day():
2525
+ return
2526
+
2527
+ # Get script reference for slippage
2528
+ script = lib._script
2529
+
2530
+ # Skip market exit order processing if there's no open position (TradingView behavior)
2531
+ if not self.open_trades:
2532
+ # Remove orphan exit orders when position is flat. An exit is orphan
2533
+ # when its ``order_id`` (the ``from_entry`` it was bound to) no longer
2534
+ # has a pending entry — the entry was cancelled, margin-rejected, or
2535
+ # never existed. Pending entries (limit/stop/market) keep their exits
2536
+ # alive so the stop/limit fires once the entry fills.
2537
+ for order in list(self.exit_orders.values()):
2538
+ if not order.is_market_order:
2539
+ if order.order_id in self.entry_orders:
2540
+ continue
2541
+ if order.from_entry_na:
2542
+ continue
2543
+ self._remove_order(order)
2544
+
2545
+ # For exit orders, calculate limit/stop from entry price if ticks are specified
2546
+ for order in self.exit_orders.values():
2547
+ # Try to find the trade with matching entry_id
2548
+ entry_price: float | None = None
2549
+ for trade in self.open_trades:
2550
+ if trade.entry_id == order.order_id:
2551
+ entry_price = trade.entry_price
2552
+ break
2553
+
2554
+ # If we found the entry price and have tick values, calculate the actual prices
2555
+ if entry_price is not None:
2556
+ # Determine direction from the order
2557
+ direction = 1.0 if order.size < 0 else -1.0 # Exit order size is negative of position
2558
+ changed = False
2559
+
2560
+ # Calculate limit from profit_ticks if specified
2561
+ if order.profit_ticks is not None and order.limit is None:
2562
+ order.limit = entry_price + direction * syminfo.mintick * order.profit_ticks
2563
+ order.limit = _price_round(order.limit, direction)
2564
+ changed = True
2565
+
2566
+ # Calculate stop from loss_ticks if specified
2567
+ if order.loss_ticks is not None and order.stop is None:
2568
+ order.stop = entry_price - direction * syminfo.mintick * order.loss_ticks
2569
+ order.stop = _price_round(order.stop, -direction)
2570
+ changed = True
2571
+
2572
+ # Calculate trail_price from trail_points_ticks if specified
2573
+ if order.trail_points_ticks is not None and order.trail_price is None:
2574
+ order.trail_price = entry_price + direction * syminfo.mintick * order.trail_points_ticks
2575
+ order.trail_price = _price_round(order.trail_price, direction)
2576
+ changed = True
2577
+
2578
+ # Update orderbook only when prices were actually calculated
2579
+ if changed:
2580
+ self.orderbook.add_order(order)
2581
+
2582
+ # Check for stop/limit orders that should be converted to market orders
2583
+ for order in self.orderbook.iter_orders():
2584
+ # Check if the order would be filled immediately (e.g. due to a gap)
2585
+ if self._check_already_filled(order):
2586
+ if order.exit_id is not None:
2587
+ # Exit order gaps through — check if its bound entry still
2588
+ # has open quantity on the ledger (the FIFO fill may have
2589
+ # consumed its trade rows while the binding stays live)
2590
+ has_open_trade = order.order_id in self._entry_open_ledger
2591
+ if not has_open_trade:
2592
+ associated_entry = self.entry_orders.get(order.order_id)
2593
+ if associated_entry is not None:
2594
+ # Pending entry exists — defer exit, will fill after entry
2595
+ continue
2596
+ # Keep from_entry_na exits — they persist until filled or replaced
2597
+ if order.from_entry_na:
2598
+ continue
2599
+ self._remove_order(order)
2600
+ continue
2601
+
2602
+ # Convert to market order
2603
+ order.is_market_order = True
2604
+ # Add to market orders dict
2605
+ self.market_orders[_market_order_key(order)] = order
2606
+
2607
+ # Reversal context for the pre-fill margin reject below. A genuine fresh entry
2608
+ # that cannot be margined at its fill price is rejected outright (TV-verified).
2609
+ # But the new leg of a reversal — an opposite-direction entry processed after a
2610
+ # same-bar close has already flattened the previous position — is NOT rejected:
2611
+ # TV fills it and lets the bar-open margin call trim the over-margin excess to a
2612
+ # viable remainder. Track the bar-start position sign and whether a same-bar close
2613
+ # has filled, so the reject can distinguish the two cases.
2614
+ reversal_pre_sign = self.sign
2615
+ reversal_close_filled = False
2616
+ # Position size AND equity before any market order fills this bar. A
2617
+ # same-direction entry that only over-margins because a PRIOR same-bar
2618
+ # entry already filled (a pyramid stack) is affordable against this base —
2619
+ # TV fills it and the bar-open margin call trims the aggregate, so it is
2620
+ # not rejected. The first fill also shifts self.equity via its open P&L,
2621
+ # so the standalone affordability test must use the bar-start equity too.
2622
+ bar_start_size = self.size
2623
+ bar_start_equity = float(self.equity)
2624
+ # Sign of the position as established by an entry filled earlier in THIS
2625
+ # bar-open cycle. A later opposite entry that would reverse such a
2626
+ # same-bar position is margin-gated on BOTH legs at once (see the
2627
+ # same-bar reversal check below); a prior-bar position never set this,
2628
+ # so it keeps the normal net-margin reversal.
2629
+ same_bar_entry_sign = 0.0
2630
+
2631
+ # Process Market orders
2632
+ for order in list(self.market_orders.values()):
2633
+ if order.cancelled:
2634
+ continue
2635
+ if order.order_type == _order_type_entry:
2636
+ if order.limit is None and order.stop is None:
2637
+ # We need to check pyramiding and flip quantity here for market orders :-/
2638
+ # Check pyramiding limit for entry orders adding to existing position
2639
+ if self.sign == order.sign:
2640
+ if lib._script.pyramiding <= len(self.open_trades):
2641
+ # Pyramiding limit reached - don't add the order
2642
+ self._remove_order(order)
2643
+ continue
2644
+ elif self.size != 0.0:
2645
+ # TradingView calculates the flip quantity 1st order processing
2646
+ # then open a new one in the opposite direction.
2647
+ order.size -= self.size # Subtract because position.size has opposite sign
2648
+ if order.deferred_qty:
2649
+ order.flip_extra = abs(self.size)
2650
+ if order.size == 0.0:
2651
+ # Closing-leg-only reversal marker whose opposite position
2652
+ # is already gone: nothing left to close.
2653
+ self._remove_order(order)
2654
+ continue
2655
+
2656
+ # Apply slippage to market orders
2657
+ fill_price = self.o
2658
+ if script.slippage > 0:
2659
+ # Slippage is in ticks, always adverse to trade direction
2660
+ # For long orders (buying), slippage increases the price
2661
+ # For short orders (selling), slippage decreases the price
2662
+ slippage_amount = syminfo.mintick * script.slippage * order.sign
2663
+ fill_price = self.o + slippage_amount
2664
+
2665
+ # Pre-fill margin check for entry orders (TradingView behavior)
2666
+ # TV rejects entry orders BEFORE filling if the position would exceed margin
2667
+ if order.order_type == _order_type_entry:
2668
+ # Settle a default-sized order's quantity at its fill price first,
2669
+ # so the margin check judges the real fill, not the estimate
2670
+ if order.deferred_qty:
2671
+ self._resolve_deferred_qty(order, fill_price)
2672
+ if order.size == 0.0:
2673
+ self._remove_order(order)
2674
+ continue
2675
+ # Same-bar opposite entry reversing a position OPENED earlier in
2676
+ # this same bar-open cycle: TV margins BOTH legs at once (the
2677
+ # closing leg's margin is not freed before the opening leg is
2678
+ # gated), so the reversing entry is rejected — the first entry's
2679
+ # position is kept — when old + new margin exceeds equity.
2680
+ # Verified with a live TradingView probe on BINANCE:BTCUSDT: a
2681
+ # same-bar 0.9 BTC pair (~55% equity each leg) rejects the flip,
2682
+ # while a PRIOR-bar reversal at the same size fills (its close
2683
+ # frees margin first — the normal net check below handles that).
2684
+ if (same_bar_entry_sign != 0.0 and self.size != 0.0
2685
+ and self.sign == same_bar_entry_sign
2686
+ and order.sign == -same_bar_entry_sign):
2687
+ pv = syminfo.pointvalue
2688
+ ratio_old = (script.margin_short if self.sign < 0
2689
+ else script.margin_long) / 100.0
2690
+ ratio_new = (script.margin_short if order.sign < 0
2691
+ else script.margin_long) / 100.0
2692
+ old_margin = abs(self.size) * fill_price * pv * ratio_old
2693
+ new_margin = abs(self.size + order.size) * fill_price * pv * ratio_new
2694
+ if (old_margin + new_margin) - self.equity > abs(self.equity) * 1e-11:
2695
+ self._cancel_same_bar_reversal_closes(order)
2696
+ self._remove_order(order)
2697
+ continue
2698
+ if self._entry_exceeds_margin_after_fill(order, fill_price):
2699
+ # The reversal's new leg (opposite the bar-start position, with a
2700
+ # same-bar close already filled) is allowed to fill and is trimmed by
2701
+ # the bar-open margin call below; only a fresh entry is hard-rejected.
2702
+ is_reversal_leg = (reversal_close_filled
2703
+ and reversal_pre_sign != 0.0
2704
+ and order.sign == -reversal_pre_sign)
2705
+ # A same-direction entry that fits against the bar-start position
2706
+ # and only over-margins because a prior same-bar entry already
2707
+ # filled (a pyramid stack) is likewise filled + margin-call trimmed,
2708
+ # not hard-rejected: it cleared its placement-time margin check.
2709
+ stacks_on_same_bar_fill = (
2710
+ self.size != bar_start_size
2711
+ and not self._entry_exceeds_margin_after_fill(
2712
+ order, fill_price, base_size=bar_start_size,
2713
+ base_equity=bar_start_equity))
2714
+ if not is_reversal_leg and not stacks_on_same_bar_fill:
2715
+ self._cancel_same_bar_reversal_closes(order)
2716
+ self._remove_order(order)
2717
+ continue
2718
+
2719
+ # open → high → low → close
2720
+ if ohlc:
2721
+ self.fill_order(order, fill_price, self.o, self.l)
2722
+ # open → low → high → close
2723
+ else:
2724
+ self.fill_order(order, fill_price, self.l, self.o)
2725
+
2726
+ # A same-bar close that reduced the bar-start position arms the reversal-leg
2727
+ # bypass for a subsequent opposite over-margin entry on this bar.
2728
+ if order.order_type == _order_type_close and reversal_pre_sign != 0.0:
2729
+ reversal_close_filled = True
2730
+ # A filled market entry establishes the same-bar direction that a
2731
+ # later opposite entry must both-legs-margin against (guard above).
2732
+ elif (order.order_type == _order_type_entry
2733
+ and order.limit is None and order.stop is None):
2734
+ same_bar_entry_sign = order.sign
2735
+
2736
+ # Convert tick-based exit prices for entries that just filled this bar
2737
+ for order in self.exit_orders.values():
2738
+ entry_price = None
2739
+ for trade in self.open_trades:
2740
+ if trade.entry_id == order.order_id:
2741
+ entry_price = trade.entry_price
2742
+ break
2743
+ if entry_price is not None:
2744
+ direction = 1.0 if order.size < 0 else -1.0
2745
+ changed = False
2746
+ if order.profit_ticks is not None and order.limit is None:
2747
+ order.limit = entry_price + direction * syminfo.mintick * order.profit_ticks
2748
+ order.limit = _price_round(order.limit, direction)
2749
+ changed = True
2750
+ if order.loss_ticks is not None and order.stop is None:
2751
+ order.stop = entry_price - direction * syminfo.mintick * order.loss_ticks
2752
+ order.stop = _price_round(order.stop, -direction)
2753
+ changed = True
2754
+ if order.trail_points_ticks is not None and order.trail_price is None:
2755
+ order.trail_price = entry_price + direction * syminfo.mintick * order.trail_points_ticks
2756
+ order.trail_price = _price_round(order.trail_price, direction)
2757
+ changed = True
2758
+ if changed:
2759
+ self.orderbook.add_order(order)
2760
+
2761
+ # Adapt orphaned exits from rejected entries to new position (TradingView behavior)
2762
+ # When strategy.exit() is called without from_entry, TV keeps the exit even after
2763
+ # its entry is rejected by margin. The exit adapts to close any new position that opens.
2764
+ if self.open_trades:
2765
+ for order in list(self.exit_orders.values()):
2766
+ if order.is_market_order:
2767
+ continue
2768
+ # Skip exits whose bound entry still has open quantity on the
2769
+ # ledger (they belong to the current position)
2770
+ if order.order_id in self._entry_open_ledger:
2771
+ continue
2772
+ # Skip exits whose entry is still pending
2773
+ if order.order_id in self.entry_orders:
2774
+ continue
2775
+ # Only a from_entry-less exit adapts to the surviving position
2776
+ # (TV keeps such an exit alive across a rejected entry). A leg
2777
+ # bound to an explicit from_entry can only ever close trades
2778
+ # from that entry — when the entry is gone it stays dormant.
2779
+ if not order.from_entry_na:
2780
+ continue
2781
+ new_sign = -self.sign
2782
+ self._remove_order(order)
2783
+ adapted = Order(
2784
+ None, -self.size, exit_id=order.exit_id,
2785
+ order_type=_order_type_close,
2786
+ limit=order.limit, stop=order.stop,
2787
+ comment=order.comment,
2788
+ comment_profit=order.comment_profit,
2789
+ comment_loss=order.comment_loss,
2790
+ comment_trailing=order.comment_trailing,
2791
+ alert_message=order.alert_message,
2792
+ alert_profit=order.alert_profit,
2793
+ alert_loss=order.alert_loss,
2794
+ alert_trailing=order.alert_trailing,
2795
+ )
2796
+ adapted.bar_index = order.bar_index
2797
+ # Check gap-through with the flipped direction
2798
+ stop_gap = (adapted.stop is not None
2799
+ and ((new_sign > 0 and self.o >= adapted.stop)
2800
+ or (new_sign < 0 and self.o <= adapted.stop)))
2801
+ limit_gap = (adapted.limit is not None
2802
+ and ((new_sign > 0 and self.o <= adapted.limit)
2803
+ or (new_sign < 0 and self.o >= adapted.limit)))
2804
+ filled = False
2805
+ if stop_gap:
2806
+ fill_price = self.o
2807
+ if script.slippage > 0:
2808
+ fill_price += syminfo.mintick * script.slippage * new_sign
2809
+ adapted.filled_by_type = 'loss'
2810
+ if ohlc:
2811
+ self.fill_order(adapted, fill_price, fill_price, self.l)
2812
+ else:
2813
+ self.fill_order(adapted, fill_price, self.l, fill_price)
2814
+ filled = True
2815
+ elif limit_gap:
2816
+ adapted.filled_by_type = 'profit'
2817
+ if ohlc:
2818
+ self.fill_order(adapted, self.o, self.o, self.l)
2819
+ else:
2820
+ self.fill_order(adapted, self.o, self.l, self.o)
2821
+ filled = True
2822
+ else:
2823
+ self._add_order(adapted)
2824
+ # If the adapted exit closed the position, clean up remaining orphan exits
2825
+ if filled and not self.open_trades:
2826
+ for remaining in list(self.exit_orders.values()):
2827
+ if not remaining.is_market_order:
2828
+ has_entry = remaining.order_id in self.entry_orders
2829
+ if not has_entry:
2830
+ self._remove_order(remaining)
2831
+ break
2832
+
2833
+ # Fill gap-through exits whose entries just filled
2834
+ for order in list(self.exit_orders.values()):
2835
+ if order.is_market_order:
2836
+ continue
2837
+ if order.order_id not in self._entry_open_ledger:
2838
+ continue
2839
+ # Check limit gap-through
2840
+ if order.limit is not None:
2841
+ limit_gap = ((order.size > 0 and self.o <= order.limit)
2842
+ or (order.size < 0 and self.o >= order.limit))
2843
+ if limit_gap:
2844
+ order.filled_by_type = 'profit'
2845
+ if ohlc:
2846
+ self.fill_order(order, self.o, self.o, self.l)
2847
+ else:
2848
+ self.fill_order(order, self.o, self.l, self.o)
2849
+ continue
2850
+ # Check stop gap-through
2851
+ if order.stop is not None:
2852
+ stop_gap = ((order.size > 0 and self.o >= order.stop)
2853
+ or (order.size < 0 and self.o <= order.stop))
2854
+ if stop_gap:
2855
+ fill_price = self.o
2856
+ if script.slippage > 0:
2857
+ fill_price += syminfo.mintick * script.slippage * order.sign
2858
+ order.filled_by_type = 'loss'
2859
+ if ohlc:
2860
+ self.fill_order(order, fill_price, fill_price, self.l)
2861
+ else:
2862
+ self.fill_order(order, fill_price, self.l, fill_price)
2863
+ continue
2864
+
2865
+ # Margin call check at OPEN — sized exactly like the intrabar (H/L)
2866
+ # liquidations: 4x the shortfall in lot units, and only when the
2867
+ # shortfall truncates below one lot does it fall back to closing a
2868
+ # single whole contract (the ``cover_lots == 0`` branch in the callee).
2869
+ # A sub-lot open overshoot (fill price a tick above the sizing price)
2870
+ # therefore still trims exactly 1.0 contract, while a multi-lot
2871
+ # overshoot trims the fractional cover TV's exported trades show
2872
+ # (BINANCE:BTCUSDT 30m RCI Strategy: a 90-lot open shortfall trims
2873
+ # 0.0038 BTC, not a whole contract). The sign gates mirror the callee's
2874
+ # own direction guards (a liquidation never reverses the position, so
2875
+ # the second direction stays a no-op after the first fires).
2876
+ if self.sign < 0:
2877
+ self._check_margin_call(self.o, for_short=True, at_open=True)
2878
+ elif self.sign > 0:
2879
+ self._check_margin_call(self.o, for_short=False, at_open=True)
2880
+
2881
+ def _process_limit_stop_orders(self, ohlc: bool):
2882
+ """Phase 2: Process limit/stop/trailing orders with margin checks at H/L."""
2883
+ # The order-book walks are gated on ``price_levels`` at each walk site
2884
+ # (re-checked, not hoisted — margin fills and trailing stops mutate the
2885
+ # book between walks); an empty book makes every walk yield nothing, so
2886
+ # skipping the generator is exactly behaviour-preserving. The margin
2887
+ # checks are gated on the position sign, mirroring the callee's own
2888
+ # direction guards — a mismatched direction is a guaranteed ``False``.
2889
+ # Trailing stops walk the assumed intrabar path themselves (arming,
2890
+ # water-mark ratchet and fill in chronological order), so they are
2891
+ # processed here rather than inside the level-indexed walk — but only
2892
+ # up to the second extreme. A fill on the walk's closing leg happens
2893
+ # chronologically AFTER the intrabar margin-call checkpoints at the
2894
+ # extremes, so orders still pending after the first two legs are
2895
+ # collected and resumed at the closing-leg site below; walking them
2896
+ # to completion here would flatten the position before a margin call
2897
+ # TV fires at the adverse extreme (verified against a TV export where
2898
+ # a partial 'Margin call' at the high preceded the trailing exit
2899
+ # filling near the low of the same bar).
2900
+ # Iterate a snapshot since fills mutate the order book; an order indexed at
2901
+ # several price levels is yielded once per level, so dedupe by identity.
2902
+ trail_close_leg: list[Order] = []
2903
+ if self.orderbook.price_levels:
2904
+ seen: set[Order] = set()
2905
+ for order in list(self.orderbook.iter_orders()):
2906
+ if order in seen or order.cancelled or order.trail_price is None:
2907
+ continue
2908
+ seen.add(order)
2909
+ if self._process_trailing_stop(order, ohlc) == _trail_pending:
2910
+ trail_close_leg.append(order)
2911
+
2912
+ # Process orders: open → high → low → close
2913
+ if ohlc:
2914
+ # open -> high
2915
+ if self.orderbook.price_levels:
2916
+ for order in self.orderbook.iter_orders(min_price=self.o, max_price=self.h):
2917
+ if self._check_high_stop(order):
2918
+ continue
2919
+ if self._check_high(order):
2920
+ continue
2921
+
2922
+ mc_deferred = self.sign < 0 and self._check_margin_call(self.h, for_short=True)
2923
+ if not mc_deferred:
2924
+ # The checkpoint at the position's FAVORABLE extreme runs
2925
+ # before this leg's fills. Under the float trigger it is a
2926
+ # no-op (available funds only improve toward the favorable
2927
+ # side at margin <= 100%), but the >=1e7 integer-tick trigger
2928
+ # can trip there: TV liquidated one contract of a LONG at
2929
+ # H=120300 (Hybrid 2025-10-02 16:00) before the exit limit at
2930
+ # 120290.7 — lower on the same leg — filled the rest.
2931
+ if self.sign < 0:
2932
+ self._check_margin_call(self.l, for_short=True, can_defer=False)
2933
+
2934
+ # open -> low (descending: the level nearest the open fills first)
2935
+ if self.orderbook.price_levels:
2936
+ for order in self.orderbook.iter_orders(max_price=self.o, min_price=self.l, desc=True):
2937
+ if self._check_low_stop(order):
2938
+ continue
2939
+ if self._check_low(order):
2940
+ continue
2941
+
2942
+ if self.sign > 0:
2943
+ self._check_margin_call(self.l, for_short=False, can_defer=False)
2944
+
2945
+ # Trailing fills on the closing leg — chronologically after both
2946
+ # margin-call checkpoints, so a partial liquidation at the extreme
2947
+ # trims the position the trailing exit then closes. A deferred
2948
+ # margin call stops the level walks but not the trail: its fill
2949
+ # precedes the close-price liquidation.
2950
+ for order in trail_close_leg:
2951
+ if order.cancelled or order.filled_by_type is not None:
2952
+ continue
2953
+ self._process_trailing_stop(order, ohlc, close_leg=True)
2954
+
2955
+ if not mc_deferred:
2956
+ # low -> close (ascending): the walk's closing leg. Orders that
2957
+ # became active mid-bar — an exit whose entry filled on an
2958
+ # earlier leg — get the path's final segment, like TV does.
2959
+ if self.orderbook.price_levels:
2960
+ for order in self.orderbook.iter_orders(min_price=self.l, max_price=self.c):
2961
+ if self._check_close_leg_up(order):
2962
+ continue
2963
+
2964
+ # Process orders: open → low → high → close
2965
+ else:
2966
+ # open -> low (descending: the level nearest the open fills first)
2967
+ if self.orderbook.price_levels:
2968
+ for order in self.orderbook.iter_orders(max_price=self.o, min_price=self.l, desc=True):
2969
+ if self._check_low_stop(order):
2970
+ continue
2971
+ if self._check_low(order):
2972
+ continue
2973
+
2974
+ mc_deferred = self.sign > 0 and self._check_margin_call(self.l, for_short=False)
2975
+ if not mc_deferred:
2976
+ # Favorable-extreme checkpoint before this leg's fills — see
2977
+ # the mirrored comment in the OHLC branch (TV-verified on the
2978
+ # Hybrid 2025-10-02 16:00 long margin call at the high).
2979
+ if self.sign > 0:
2980
+ self._check_margin_call(self.h, for_short=False, can_defer=False)
2981
+
2982
+ # open -> high
2983
+ if self.orderbook.price_levels:
2984
+ for order in self.orderbook.iter_orders(min_price=self.o, max_price=self.h):
2985
+ if self._check_high_stop(order):
2986
+ continue
2987
+ if self._check_high(order):
2988
+ continue
2989
+
2990
+ if self.sign < 0:
2991
+ self._check_margin_call(self.h, for_short=True, can_defer=False)
2992
+
2993
+ # Trailing fills on the closing leg — chronologically after both
2994
+ # margin-call checkpoints, so a partial liquidation at the extreme
2995
+ # trims the position the trailing exit then closes. A deferred
2996
+ # margin call stops the level walks but not the trail: its fill
2997
+ # precedes the close-price liquidation.
2998
+ for order in trail_close_leg:
2999
+ if order.cancelled or order.filled_by_type is not None:
3000
+ continue
3001
+ self._process_trailing_stop(order, ohlc, close_leg=True)
3002
+
3003
+ if not mc_deferred:
3004
+ # high -> close (descending): the walk's closing leg. Orders that
3005
+ # became active mid-bar — an exit whose entry filled on an
3006
+ # earlier leg — get the path's final segment, like TV does.
3007
+ if self.orderbook.price_levels:
3008
+ for order in self.orderbook.iter_orders(max_price=self.h, min_price=self.c, desc=True):
3009
+ if self._check_close_leg_down(order):
3010
+ continue
3011
+
3012
+ def _finalize_bar_pnl(self):
3013
+ """Phase 3: Calculate P&L, drawdown, runup, and cumulative stats."""
3014
+ # Calculate average entry price, unrealized P&L, drawdown and runup...
3015
+ if self.open_trades:
3016
+ # USD value per 1.0-point move per 1 contract — futures-aware PnL conversion factor
3017
+ pv = syminfo.pointvalue
3018
+
3019
+ # Unrealized P&L
3020
+ self.openprofit = self.size * (self.c - self.avg_price) * pv
3021
+
3022
+ # Calculate open drawdowns and runups
3023
+ for trade in self.open_trades:
3024
+ # Profit of trade
3025
+ trade.profit = trade.size * (self.c - trade.entry_price) * pv - 2 * trade.commission
3026
+
3027
+ # P/L from high/low to calculate drawdown and runup
3028
+ hprofit = trade.size * (self.h - self.avg_price) * pv - trade.commission
3029
+ lprofit = trade.size * (self.l - self.avg_price) * pv - trade.commission
3030
+ # Drawdown
3031
+ drawdown = -min(hprofit, lprofit, 0.0)
3032
+ trade.max_drawdown = max(drawdown, trade.max_drawdown)
3033
+ # Runup
3034
+ runup = max(hprofit, lprofit, 0.0)
3035
+ trade.max_runup = max(runup, trade.max_runup)
3036
+
3037
+ # Calculate percentage values for drawdown and runup — both in USD
3038
+ trade_value = abs(trade.size) * trade.entry_price * pv
3039
+ if trade_value > 0:
3040
+ # Calculate drawdown percentage
3041
+ trade.max_drawdown_percent = max(
3042
+ (drawdown / trade_value) * 100.0 if drawdown > 0 else 0.0,
3043
+ trade.max_drawdown_percent
3044
+ )
3045
+
3046
+ # Calculate runup percentage
3047
+ trade.max_runup_percent = max(
3048
+ (runup / trade_value) * 100.0 if runup > 0 else 0.0,
3049
+ trade.max_runup_percent
3050
+ )
3051
+
3052
+ # Drawdown summ runup summ
3053
+ self.drawdown_summ += drawdown
3054
+ self.runup_summ += runup
3055
+
3056
+ # Calculate max drawdown and runup
3057
+ if self.drawdown_summ or self.runup_summ:
3058
+ self.max_drawdown = max(self.max_drawdown, self.max_equity - self.entry_equity + self.drawdown_summ)
3059
+ self.max_runup = max(self.max_runup, self.entry_equity - self.min_equity + self.runup_summ)
3060
+
3061
+ # --- Fork-parity intrabar / TV-style drawdown accumulators (P5) ---
3062
+ initial_capital = lib._script.initial_capital
3063
+ commission_type = lib._script.commission_type
3064
+ commission_value = lib._script.commission_value
3065
+ pv = syminfo.pointvalue
3066
+
3067
+ # Real max drawdown: max sum of unrealized losses from losing open trades
3068
+ if self.open_trades:
3069
+ open_loss = 0.0
3070
+ total_cost = 0.0
3071
+ for trade in self.open_trades:
3072
+ if trade.profit < 0:
3073
+ open_loss += trade.profit
3074
+ total_cost += abs(trade.size) * trade.entry_price * pv
3075
+ if open_loss < 0:
3076
+ current_dd = -open_loss
3077
+ current_dd_pct = (current_dd / total_cost) * 100.0 if total_cost != 0 else 0.0
3078
+ self.real_max_drawdown = max(self.real_max_drawdown, current_dd)
3079
+ self.real_max_drawdown_percent = max(self.real_max_drawdown_percent, current_dd_pct)
3080
+
3081
+ # Unrealized (intrabar) max drawdown: worst-case open P&L this bar,
3082
+ # anchored to peak REALIZED equity (TV Max_Equity reference).
3083
+ worst_case_open_pnl = 0.0
3084
+ if self.open_trades:
3085
+ for trade in self.open_trades:
3086
+ worst_price = self.l if trade.size > 0 else self.h
3087
+ raw_pnl = (worst_price - trade.entry_price) * trade.size * pv
3088
+ if commission_type == _commission.percent:
3089
+ comm_cost = abs(trade.size) * trade.entry_price * pv * commission_value * 0.01
3090
+ elif commission_type == _commission.cash_per_contract:
3091
+ comm_cost = abs(trade.size) * commission_value
3092
+ elif commission_type == _commission.cash_per_order:
3093
+ comm_cost = commission_value
3094
+ else:
3095
+ comm_cost = 0.0
3096
+ worst_case_open_pnl += raw_pnl - comm_cost
3097
+
3098
+ realized_equity = initial_capital + self.netprofit
3099
+ worst_equity = realized_equity + worst_case_open_pnl
3100
+ self.peak_realized_equity = max(self.peak_realized_equity, realized_equity)
3101
+ drawdown_from_peak = self.peak_realized_equity - worst_equity
3102
+ if drawdown_from_peak > 0:
3103
+ self.unrealized_max_drawdown = max(self.unrealized_max_drawdown, drawdown_from_peak)
3104
+ _dd_pct = (drawdown_from_peak / self.peak_realized_equity) * 100.0 \
3105
+ if self.peak_realized_equity != 0 else 0.0
3106
+ self.unrealized_max_drawdown_percent = max(self.unrealized_max_drawdown_percent, _dd_pct)
3107
+
3108
+ def _finalize_new_closed_trades(self) -> None:
3109
+ """Apply cumulative stats to every trade closed on this bar.
3110
+
3111
+ Split out from :meth:`_finalize_bar_pnl` so it runs **after**
3112
+ :meth:`_enforce_post_bar_risk` — otherwise a synthetic close
3113
+ emitted by a risk-rule halt would be appended to
3114
+ ``new_closed_trades`` after this loop has finished, ship out with
3115
+ default ``cum_profit`` / ``cum_max_drawdown`` / ``cum_max_runup``
3116
+ / ``cum_profit_percent`` values, and never be revisited.
3117
+ """
3118
+ if not self.new_closed_trades:
3119
+ return
3120
+ initial_capital = lib._script.initial_capital
3121
+ for closed_trade in self.new_closed_trades:
3122
+ # Incrementally add each trade's profit to cumulative total
3123
+ self.cum_profit += closed_trade.profit
3124
+ closed_trade.cum_profit = self.cum_profit
3125
+ closed_trade.cum_max_drawdown = self.max_drawdown
3126
+ closed_trade.cum_max_runup = self.max_runup
3127
+
3128
+ # Cumulative profit percent
3129
+ try:
3130
+ closed_trade.cum_profit_percent = (closed_trade.cum_profit / initial_capital) * 100.0
3131
+ except ZeroDivisionError:
3132
+ closed_trade.cum_profit_percent = 0.0
3133
+
3134
+ # Modify entry equity, for max drawdown and runup
3135
+ self.entry_equity += closed_trade.profit
3136
+
3137
+ def process_orders_at_close(self):
3138
+ """
3139
+ Optional post-script pass that fills current-bar-submitted orders at the bar's
3140
+ CLOSE — enabled by `script.process_orders_on_close=True`.
3141
+
3142
+ Pine semantics: when the flag is set, orders placed during the strategy's bar
3143
+ calculation get an additional fill attempt at the bar close, instead of waiting
3144
+ for the next bar's open. This covers BOTH:
3145
+ - Market orders: trivially executable at close.
3146
+ - Limit/stop orders: executable when the close has reached/crossed the trigger
3147
+ price. (Non-current-bar limit/stop orders already had their fair shake in
3148
+ `_process_limit_stop_orders` during the H/L walk.)
3149
+ Tick-based exit orders submitted on the current bar (`strategy.exit(profit=...,
3150
+ loss=...)`) only carry `profit_ticks` / `loss_ticks` until the next bar's
3151
+ `_process_at_bar_open` resolves them against the entry price. The close-pass
3152
+ materializes those into `limit` / `stop` first so the trigger check sees them.
3153
+
3154
+ Fill price in every case is `self.c` (Pine fills price-based orders "when their
3155
+ limit or stop price is hit on the close" — no trigger-price snap on the close
3156
+ pass). Slippage matches the rest of the engine: applied to market and
3157
+ stop-triggered fills, NOT to limit-triggered fills (Pine guarantees limit
3158
+ orders fill at the limit price or better). `filled_by_type` is set on the
3159
+ triggering order so `_fill_order` can attach the right exit comment.
3160
+
3161
+ Bookkeeping note: `_finalize_bar_pnl()` already ran in `process_orders()` for the
3162
+ same bar. Re-running it here would double-count `cum_profit` / `entry_equity` for
3163
+ already-settled `new_closed_trades` and dupe the `drawdown_summ` / `runup_summ`
3164
+ contribution of open trades. Instead, we only settle cumulative stats for trades
3165
+ that close DURING this pass (`_settle_close_pass_trades`). For positions opened
3166
+ right at the close, the bar has no remaining H/L range — their per-trade
3167
+ `profit` / `max_drawdown_percent` are intentionally left for the next bar's
3168
+ `_finalize_bar_pnl()` to compute, when there will actually be a range to attribute.
3169
+ """
3170
+ script = lib._script
3171
+ current_bar = int(lib.bar_index)
3172
+ close = self.c
3173
+
3174
+ # Collect current-bar candidates: market orders (trivially eligible) and
3175
+ # limit/stop orders whose trigger condition is already met by the close.
3176
+ # Each entry carries the trigger kind so slippage / `filled_by_type` mirror
3177
+ # the regular fill paths (`_check_high_stop` etc.).
3178
+ # Use id() as the dedup key — order objects may live in multiple dicts.
3179
+ candidates: list[tuple[Order, str]] = []
3180
+ seen: set[int] = set()
3181
+
3182
+ def _materialize_tick_exit(order: Order) -> None:
3183
+ """Resolve profit_ticks/loss_ticks against the matching open trade.
3184
+
3185
+ Mirrors `_process_at_bar_open`: exits submitted during this bar's main()
3186
+ still carry the raw tick offsets — the close-pass trigger check needs
3187
+ them as concrete limit/stop prices.
3188
+ """
3189
+ if order.profit_ticks is None and order.loss_ticks is None:
3190
+ return
3191
+ if order.limit is not None and order.stop is not None:
3192
+ return
3193
+ entry_price: float | None = None
3194
+ for trade in self.open_trades:
3195
+ if trade.entry_id == order.order_id:
3196
+ entry_price = trade.entry_price
3197
+ break
3198
+ if entry_price is None:
3199
+ return
3200
+ direction = 1.0 if order.size < 0 else -1.0
3201
+ changed = False
3202
+ if order.profit_ticks is not None and order.limit is None:
3203
+ order.limit = _price_round(
3204
+ entry_price + direction * syminfo.mintick * order.profit_ticks,
3205
+ direction,
3206
+ )
3207
+ changed = True
3208
+ if order.loss_ticks is not None and order.stop is None:
3209
+ order.stop = _price_round(
3210
+ entry_price - direction * syminfo.mintick * order.loss_ticks,
3211
+ -direction,
3212
+ )
3213
+ changed = True
3214
+ # If we just resolved the order's price levels, index it in the
3215
+ # orderbook (mirrors `_process_at_bar_open`). Without this, an order
3216
+ # that fails the close-pass trigger check would persist with
3217
+ # `limit`/`stop` set but absent from `PriceOrderBook`, so next bar's
3218
+ # H/L walk would never see it (next bar's tick conversion is skipped
3219
+ # because `limit`/`stop` are already non-None).
3220
+ if changed:
3221
+ self.orderbook.add_order(order)
3222
+
3223
+ def _add_market(order: Order):
3224
+ oid = id(order)
3225
+ if oid in seen or order.cancelled or order.bar_index != current_bar:
3226
+ return
3227
+ seen.add(oid)
3228
+ candidates.append((order, 'market'))
3229
+
3230
+ def _add_trigger(order: Order):
3231
+ oid = id(order)
3232
+ if oid in seen or order.cancelled or order.bar_index != current_bar:
3233
+ return
3234
+ if order.is_market_order:
3235
+ return
3236
+ if order.order_type == _order_type_close:
3237
+ if self._exit_awaits_entry(order):
3238
+ return
3239
+ _materialize_tick_exit(order)
3240
+ trigger: str | None = None
3241
+ if order.stop is not None:
3242
+ if order.sign > 0 and close >= order.stop:
3243
+ trigger = 'stop'
3244
+ elif order.sign < 0 and close <= order.stop:
3245
+ trigger = 'stop'
3246
+ if trigger is None and order.limit is not None:
3247
+ if order.sign > 0 and close <= order.limit:
3248
+ trigger = 'limit'
3249
+ elif order.sign < 0 and close >= order.limit:
3250
+ trigger = 'limit'
3251
+ if trigger is not None:
3252
+ seen.add(oid)
3253
+ candidates.append((order, trigger))
3254
+
3255
+ for order in list(self.market_orders.values()):
3256
+ _add_market(order)
3257
+ for order in list(self.entry_orders.values()):
3258
+ _add_trigger(order)
3259
+ for order in list(self.exit_orders.values()):
3260
+ _add_trigger(order)
3261
+
3262
+ # Bar is closed; no further H/L range can occur after the fill. Use close for both
3263
+ # so any close-pass exit attributes 0 extra drawdown/runup to itself this bar.
3264
+ h_after = close
3265
+ l_after = close
3266
+
3267
+ closed_before = len(self.new_closed_trades)
3268
+ # Snapshot drawdown / runup accumulators: `_finalize_bar_pnl()` in
3269
+ # `process_orders()` already booked the open-trade contribution for the full
3270
+ # bar H/L. `_fill_order` would add the close-pass exit PnL to the same summs,
3271
+ # double-counting the bar for any position that was already open at bar start.
3272
+ # We restore the snapshot after the fill loop, before the close-pass settle.
3273
+ drawdown_summ_before = self.drawdown_summ
3274
+ runup_summ_before = self.runup_summ
3275
+
3276
+ def _apply_fill(order: Order, trigger: str) -> None:
3277
+ """Run the per-candidate fill, mirroring `_process_at_bar_open`."""
3278
+ if order.cancelled:
3279
+ return
3280
+ if order.order_type == _order_type_entry:
3281
+ if order.limit is None and order.stop is None:
3282
+ # Pyramiding and flip-quantity handling — mirror `_process_at_bar_open`.
3283
+ if self.sign == order.sign:
3284
+ if script.pyramiding <= len(self.open_trades):
3285
+ self._remove_order(order)
3286
+ return
3287
+ elif self.size != 0.0:
3288
+ order.size -= self.size
3289
+
3290
+ # Slippage: market + stop fills get slipped against the order direction,
3291
+ # limit fills do not (Pine guarantees limit price or better — matches
3292
+ # `_check_high` / `_check_low`).
3293
+ fill_price = close
3294
+ if trigger != 'limit' and script.slippage > 0:
3295
+ fill_price = close + syminfo.mintick * script.slippage * order.sign
3296
+
3297
+ # Pass trigger reason through to `_fill_order` so close-pass exits get the
3298
+ # same `exit_comment` as their intrabar counterparts.
3299
+ if trigger == 'stop':
3300
+ order.filled_by_type = 'loss'
3301
+ elif trigger == 'limit':
3302
+ order.filled_by_type = 'profit'
3303
+
3304
+ if order.order_type == _order_type_entry:
3305
+ if self._entry_exceeds_margin_after_fill(order, fill_price):
3306
+ self._remove_order(order)
3307
+ return
3308
+
3309
+ self.fill_order(order, fill_price, h_after, l_after)
3310
+
3311
+ # Phase 1: fill the initial candidates (market entries, previously-open
3312
+ # tick exits, current-bar limit/stop orders already executable at close).
3313
+ for order, trigger in candidates:
3314
+ _apply_fill(order, trigger)
3315
+
3316
+ # Phase 2: a current-bar entry may have just filled in Phase 1, opening a
3317
+ # trade whose `entry_price` lets us resolve a same-bar `strategy.exit(...,
3318
+ # profit=..., loss=...)` order whose ticks were unresolved before Phase 1.
3319
+ # Mirror `_process_at_bar_open` line 1467-1490 — re-scan exit_orders for
3320
+ # current-bar tick exits, materialize, and fill any newly executable.
3321
+ for order in list(self.exit_orders.values()):
3322
+ oid = id(order)
3323
+ if oid in seen or order.cancelled or order.bar_index != current_bar:
3324
+ continue
3325
+ if order.is_market_order:
3326
+ continue
3327
+ if order.profit_ticks is None and order.loss_ticks is None:
3328
+ continue
3329
+ _materialize_tick_exit(order)
3330
+ trigger2: str | None = None
3331
+ if order.stop is not None:
3332
+ if order.sign > 0 and close >= order.stop:
3333
+ trigger2 = 'stop'
3334
+ elif order.sign < 0 and close <= order.stop:
3335
+ trigger2 = 'stop'
3336
+ if trigger2 is None and order.limit is not None:
3337
+ if order.sign > 0 and close <= order.limit:
3338
+ trigger2 = 'limit'
3339
+ elif order.sign < 0 and close >= order.limit:
3340
+ trigger2 = 'limit'
3341
+ if trigger2 is not None:
3342
+ seen.add(oid)
3343
+ _apply_fill(order, trigger2)
3344
+
3345
+ # Discard the close-pass `_fill_order` contributions to drawdown_summ / runup_summ:
3346
+ # the same bar's H/L range is already booked for these trades by the earlier
3347
+ # `_finalize_bar_pnl()` call. The drop-on-the-floor edge case is a brand-new
3348
+ # trade that opens AND closes within the same close pass — extremely unlikely
3349
+ # and its H/L would be 0 anyway since the bar has no remaining range.
3350
+ self.drawdown_summ = drawdown_summ_before
3351
+ self.runup_summ = runup_summ_before
3352
+
3353
+ # Incrementally settle only the trades that closed during the close pass;
3354
+ # everything settled by `process_orders()` earlier in this bar stays untouched.
3355
+ if len(self.new_closed_trades) > closed_before:
3356
+ self._settle_close_pass_trades(closed_before)
3357
+
3358
+ def _settle_close_pass_trades(self, closed_before: int):
3359
+ """
3360
+ Apply cumulative bookkeeping for trades that closed during `process_orders_at_close`.
3361
+
3362
+ Mirrors the per-closed-trade cum_profit / entry_equity update tail of
3363
+ `_finalize_bar_pnl()`, but only for new_closed_trades appended after the close
3364
+ pass started — the earlier entries were already settled when `process_orders()`
3365
+ ran for this same bar. Position-level max_drawdown / max_runup is intentionally
3366
+ NOT re-rolled here: the bar's H/L drawdown_summ / runup_summ contribution was
3367
+ already booked by `_finalize_bar_pnl()` against the open trades (which include
3368
+ the trades that close here, since they were opened on this same bar), and the
3369
+ close-pass `_fill_order` additions to those summs were discarded above. Re-
3370
+ applying the snapshot would inflate `max_drawdown` whenever `entry_equity` had
3371
+ already advanced (e.g. a losing regular-pass close shrank `entry_equity`).
3372
+ """
3373
+ initial_capital = lib._script.initial_capital
3374
+ for closed_trade in self.new_closed_trades[closed_before:]:
3375
+ self.cum_profit += closed_trade.profit
3376
+ closed_trade.cum_profit = self.cum_profit
3377
+ closed_trade.cum_max_drawdown = self.max_drawdown
3378
+ closed_trade.cum_max_runup = self.max_runup
3379
+ try:
3380
+ closed_trade.cum_profit_percent = (closed_trade.cum_profit / initial_capital) * 100.0
3381
+ except ZeroDivisionError:
3382
+ closed_trade.cum_profit_percent = 0.0
3383
+ # Entry equity must roll AFTER the max_drawdown/runup snapshot above —
3384
+ # same ordering as `_finalize_bar_pnl()`.
3385
+ self.entry_equity += closed_trade.profit
3386
+
3387
+ def settle_immediate_closes(self):
3388
+ """
3389
+ Fill the strategy.close/close_all(immediately=True) orders enqueued during
3390
+ this bar's body, at the bar close.
3391
+
3392
+ Runs right AFTER the body (before the bar's output/equity bookkeeping), so
3393
+ the whole position stays coherent — fully open — for the rest of the bar and
3394
+ every ``strategy.*`` series (``position_size``, ``position_avg_price``,
3395
+ ``netprofit``, ``equity``, ``opentrades`` …) reads its pre-close value.
3396
+ This matches TradingView and PyneCore's own broker mode, where an immediate
3397
+ close does not take effect until after the script.
3398
+
3399
+ Per-order this mirrors the old inline path exactly (snapshot →
3400
+ ``fill_order`` → ``_settle_close_pass_trades``); only the fill timing moved
3401
+ from mid-body to just-after-body. The fill price is still ``self.c`` (the
3402
+ bar close), which is unchanged between the body and this step, so exit
3403
+ price / P&L / cumulative stats are bit-identical.
3404
+ """
3405
+ orders = self._deferred_immediate_closes
3406
+ if not orders:
3407
+ return
3408
+ self._deferred_immediate_closes = [] # drain-once / re-entrancy guard
3409
+ for order in orders:
3410
+ if self.size == 0.0:
3411
+ # An earlier buffered close already flattened. TV treats a close
3412
+ # against a zero position as a no-op; drop the order so it cannot
3413
+ # zombie-fill on a later bar — ``_fill_order`` early-returns on a
3414
+ # zero-size close WITHOUT removing it from the order books.
3415
+ self._remove_order(order)
3416
+ continue
3417
+ closed_before = len(self.new_closed_trades)
3418
+ self.fill_order(order, self.c, self.h, self.l)
3419
+ self._settle_close_pass_trades(closed_before)
3420
+
3421
+ def _discard_deferred_immediate_closes(self):
3422
+ """
3423
+ Cancel immediate closes left buffered by a throwaway COOF trial body run.
3424
+
3425
+ Called at the top of ``process_orders``/``process_orders_magnified``. In
3426
+ steady state the buffer is already empty (``settle_immediate_closes``
3427
+ drained it after the previous body); this only fires between
3428
+ ``calc_on_order_fills`` re-executions, where a trial run's enqueued close
3429
+ must be undone — the position-side analog of the restored ``var`` state —
3430
+ before the next order-processing pass could wrongly fill it at the bar open.
3431
+ """
3432
+ if not self._deferred_immediate_closes:
3433
+ return
3434
+ for order in self._deferred_immediate_closes:
3435
+ self._remove_order(order)
3436
+ self._deferred_immediate_closes = []
3437
+
3438
+ def process_orders_magnified(self, sub_bars: list[OHLCV], aggregated: OHLCV):
3439
+ """
3440
+ Process orders using bar magnifier — check fills against each sub-bar's OHLC.
3441
+
3442
+ Phase 1 (at-open) runs once using first sub-bar.
3443
+ Phase 2 (limit/stop) runs on each sub-bar sequentially.
3444
+ Phase 3 (P&L) runs once using aggregated bar values.
3445
+ """
3446
+ # ``lib.math.round_to_mintick`` inlined — sub-bar OHLC are plain floats, and
3447
+ # this runs per sub-bar. Expression shape must stay left-to-right (see the
3448
+ # bit-parity note in ``lib/math.py``).
3449
+ mintick = syminfo.mintick
3450
+ minmove = syminfo.minmove
3451
+ pricescale = syminfo.pricescale
3452
+ # Setup from first sub-bar (= chart bar open)
3453
+ first = sub_bars[0]
3454
+ self.o = int(first.open / mintick + 0.5) * minmove / pricescale
3455
+ self.h = int(first.high / mintick + 0.5) * minmove / pricescale
3456
+ self.l = int(first.low / mintick + 0.5) * minmove / pricescale
3457
+ # Use aggregated close for margin deferral checks
3458
+ self.c = int(aggregated.close / mintick + 0.5) * minmove / pricescale
3459
+ self.drawdown_summ = self.runup_summ = 0.0
3460
+ self.new_closed_trades.clear()
3461
+ # Undo any immediate close a COOF trial body run enqueued (position-side
3462
+ # analog of the restored ``var`` state); no-op in the common case.
3463
+ self._discard_deferred_immediate_closes()
3464
+
3465
+ # Phase 1: at-open processing (gap detection, market orders, margin at open)
3466
+ ohlc = self.h - self.o < self.o - self.l
3467
+ self._process_at_bar_open(ohlc)
3468
+
3469
+ # Phase 2: process limit/stop orders on each sub-bar
3470
+ for sub_bar in sub_bars:
3471
+ self.o = int(sub_bar.open / mintick + 0.5) * minmove / pricescale
3472
+ self.h = int(sub_bar.high / mintick + 0.5) * minmove / pricescale
3473
+ self.l = int(sub_bar.low / mintick + 0.5) * minmove / pricescale
3474
+ self.c = int(sub_bar.close / mintick + 0.5) * minmove / pricescale
3475
+ ohlc = self.h - self.o < self.o - self.l
3476
+ self._process_limit_stop_orders(ohlc)
3477
+
3478
+ # Phase 3: P&L update using aggregated bar values
3479
+ self.h = int(aggregated.high / mintick + 0.5) * minmove / pricescale
3480
+ self.l = int(aggregated.low / mintick + 0.5) * minmove / pricescale
3481
+ self.c = int(aggregated.close / mintick + 0.5) * minmove / pricescale
3482
+ self._finalize_bar_pnl()
3483
+ if (self.risk_max_drawdown_value is not None
3484
+ or self.risk_max_intraday_loss_value is not None
3485
+ or self.risk_max_cons_loss_days is not None):
3486
+ self._enforce_post_bar_risk()
3487
+ self._finalize_new_closed_trades()
3488
+
3489
+
3490
+ #
3491
+ # Functions
3492
+ #
3493
+
3494
+ # noinspection PyProtectedMember
3495
+ def _size_round(qty: PyneFloat) -> PyneFloat:
3496
+ """
3497
+ Round a size down to the nearest tradable lot (``1 / _size_round_factor``).
3498
+
3499
+ :param qty: The quantity to round
3500
+ :return: The rounded quantity
3501
+ """
3502
+ if (isinstance(qty, NA) or qty != qty):
3503
+ return na_float
3504
+ rfactor = syminfo._size_round_factor # noqa
3505
+ # Floor to the lot step (1 / rfactor). The float64 product can land an exact
3506
+ # lot multiple a hair below the integer (e.g. 173.432 * 1e4 ->
3507
+ # 1734319.9999999998); snap values within a few ULPs of an integer up before
3508
+ # the floor so an exact multiple is not truncated a whole lot down.
3509
+ # Do NOT widen this tolerance to chase a single TV fill: the hair-below
3510
+ # razor ties (~2e-4 of boundary entries; the Gaussian Channel extra trade
3511
+ # is one) are NOT reachable by any snap width. One-shot TV probes with
3512
+ # injected equity proved the up-vs-floor outcome is a deterministic
3513
+ # function of (equity, close) following a money-tick grid law (snap up
3514
+ # iff floor(money_ticks/G) >= floor(cost_ticks(N0+1)/G), G scale-
3515
+ # dependent: 0.05 ticks near 1e6 money, 0.002 near 5e5; 615/618 probe
3516
+ # razors reproduced). The law belongs in the money-sizing path, not in
3517
+ # this generic lot floor — implementing it here as a tolerance breaks
3518
+ # ordinary fills.
3519
+ scaled = abs(qty) * rfactor
3520
+ nearest = round(scaled)
3521
+ lots = nearest if abs(scaled - nearest) <= scaled * 1e-12 + 1e-9 else int(scaled)
3522
+ sign = 1 if qty > 0 else -1
3523
+ return sign * lots / rfactor
3524
+
3525
+
3526
+ # noinspection PyShadowingNames
3527
+ @overload
3528
+ def _price_round(price: float, direction: int | float) -> float: ...
3529
+
3530
+
3531
+ # noinspection PyShadowingNames
3532
+ @overload
3533
+ def _price_round(price: PyneFloat, direction: int | float) -> PyneFloat: ...
3534
+
3535
+
3536
+ # noinspection PyShadowingNames
3537
+ def _price_round(price: PyneFloat, direction: int | float) -> PyneFloat:
3538
+ """
3539
+ Round price to the nearest tick (floor if direction < 0, ceil otherwise)
3540
+
3541
+ Uses `minmove / pricescale` (matches `lib.math.round_to_mintick`), so symbols
3542
+ with `minmove != 1` (e.g. QM1!: pricescale=1000, minmove=25, tick=0.025) snap
3543
+ to the actual tick grid instead of `1 / pricescale`.
3544
+
3545
+ :param price: The price to round
3546
+ :param direction: The direction of the price
3547
+ :return: The rounded price
3548
+ """
3549
+ if (isinstance(price, NA) or price != price):
3550
+ return na_float
3551
+ pricescale = syminfo.pricescale
3552
+ minmove = syminfo.minmove
3553
+ tick_count = round(price * pricescale / minmove, 7)
3554
+ if direction < 0:
3555
+ return int(tick_count) * minmove / pricescale
3556
+ return math.ceil(tick_count) * minmove / pricescale
3557
+
3558
+
3559
+ # noinspection PyShadowingBuiltins,PyProtectedMember
3560
+ def cancel(id: str):
3561
+ """
3562
+ Cancels a pending or unfilled order with a specific identifier
3563
+
3564
+ :param id: The identifier of the order to cancel
3565
+ """
3566
+ if lib._lib_semaphore or lib._strategy_suppressed:
3567
+ return
3568
+
3569
+ position = lib._script.position
3570
+ position._remove_order_by_id(id)
3571
+
3572
+
3573
+ # noinspection PyProtectedMember
3574
+ def cancel_all():
3575
+ """
3576
+ Cancels all pending or unfilled orders
3577
+ """
3578
+ if lib._lib_semaphore or lib._strategy_suppressed:
3579
+ return
3580
+ lib._script.position._cancel_all_orders()
3581
+
3582
+
3583
+ # noinspection PyProtectedMember,PyShadowingBuiltins,PyShadowingNames
3584
+ def close(id: str, comment: PyneStr = na_str, qty: PyneFloat = na_float,
3585
+ qty_percent: PyneFloat = na_float, alert_message: PyneStr = na_str,
3586
+ immediately: bool = False):
3587
+ """
3588
+ Creates an order to exit from the part of a position opened by entry orders with a specific identifier.
3589
+
3590
+ :param id: The identifier of the entry order to close
3591
+ :param comment: Additional notes on the filled order
3592
+ :param qty: The number of contracts/lots/shares/units to close when an exit order fills
3593
+ :param qty_percent: A value between 0 and 100 representing the percentage of the open trade
3594
+ quantity to close when an exit order fills
3595
+ :param alert_message: Custom text for the alert that fires when an order fills.
3596
+ :param immediately: If true, the closing order executes on the same tick when the strategy places it
3597
+ """
3598
+ if lib._lib_semaphore or lib._strategy_suppressed:
3599
+ return
3600
+
3601
+ position = lib._script.position
3602
+
3603
+ if not (isinstance(qty, NA) or qty != qty) and qty <= 0.0:
3604
+ return
3605
+
3606
+ if position.size == 0.0:
3607
+ return
3608
+
3609
+ # TV closes only the part of the position opened by entries with this id.
3610
+ # Under the default FIFO close_entries_rule the FILL may consume older
3611
+ # trades first, but the amount closed is still the bound entry's open size
3612
+ # — sizing off the whole position would flatten unrelated entries.
3613
+ if isinstance(position, SimPosition):
3614
+ # noinspection PyProtectedMember
3615
+ bound_size = position.sign * position._entry_open_ledger.get(id, 0.0)
3616
+ else:
3617
+ bound_size = 0.0
3618
+ adopted_size = 0.0
3619
+ for trade in position.open_trades:
3620
+ if trade.entry_id == id:
3621
+ bound_size += trade.size
3622
+ elif trade.entry_id is None or trade.entry_id == ADOPTED_STARTUP_ENTRY_ID:
3623
+ adopted_size += trade.size
3624
+ if bound_size == 0.0:
3625
+ # Startup adoption seeds the open position under a synthetic (or
3626
+ # ``None``) parent id because the real ``strategy.entry`` ids from the
3627
+ # prior process are unknown, so a keyed ``close(id)`` matches no open
3628
+ # trade. Bind it to the adopted exposure instead of dropping the close
3629
+ # (early ``size == 0.0`` return) — otherwise the script could never
3630
+ # flatten an adopted position by entry id. ``_clamp_close_intents``
3631
+ # caps this to the residual position size before dispatch.
3632
+ bound_size = adopted_size
3633
+
3634
+ if (isinstance(qty, NA) or qty != qty):
3635
+ if not (isinstance(qty_percent, NA) or qty_percent != qty_percent):
3636
+ size = _size_round(-bound_size * (qty_percent * 0.01))
3637
+ else:
3638
+ size = -bound_size
3639
+ else:
3640
+ size = _size_round(-position.sign * min(qty, abs(bound_size)))
3641
+
3642
+ if size == 0.0:
3643
+ return
3644
+
3645
+ exit_id = f"Close entry(s) order {id}"
3646
+ order = Order(id, size, exit_id=exit_id, order_type=_order_type_close,
3647
+ comment=None if isinstance(comment, NA) else comment,
3648
+ alert_message=None if isinstance(alert_message, NA) else alert_message)
3649
+
3650
+ # Stamp a unique book_seq so several same-bar partial closes on this entry
3651
+ # stack instead of colliding on a shared exit-order key. Backtest only —
3652
+ # the live broker close-dispatch path is handled separately and stays None.
3653
+ if isinstance(position, SimPosition):
3654
+ order.book_seq = position._next_close_seq()
3655
+
3656
+ # Add order to position (this will handle orderbook and exit_orders)
3657
+ position._add_order(order)
3658
+ # Same-tick fill is a backtest concept; in broker mode the order is already
3659
+ # enqueued by ``_add_order`` and the sync engine forwards it to the exchange.
3660
+ if immediately and isinstance(position, SimPosition):
3661
+ # Deferred immediate settle: fill after the body (settle_immediate_closes)
3662
+ # so position series stay at their pre-close values for the rest of the
3663
+ # bar — matching TradingView and PyneCore's broker mode.
3664
+ position._deferred_immediate_closes.append(order)
3665
+
3666
+
3667
+ # noinspection PyProtectedMember,PyShadowingNames
3668
+ def close_all(comment: PyneStr = na_str, alert_message: PyneStr = na_str, immediately: bool = False):
3669
+ """
3670
+ Creates an order to close an open position completely, regardless of the identifiers of the entry
3671
+ orders that opened or added to it.
3672
+
3673
+ :param comment: Additional notes on the filled order
3674
+ :param alert_message: Custom text for the alert that fires when an order fills
3675
+ :param immediately: If true, the closing order executes on the same tick when the strategy places it
3676
+ """
3677
+ if lib._lib_semaphore or lib._strategy_suppressed:
3678
+ return
3679
+
3680
+ position = lib._script.position
3681
+ if position.size == 0.0:
3682
+ return
3683
+
3684
+ exit_id = 'Close position order'
3685
+ order = Order(None, -position.size, exit_id=exit_id, order_type=_order_type_close,
3686
+ comment=comment, alert_message=alert_message)
3687
+
3688
+ # Stamp book_seq so a close_all stacked behind a same-bar partial close fills
3689
+ # too (backtest only; live close-dispatch handled separately, stays None).
3690
+ if isinstance(position, SimPosition):
3691
+ order.book_seq = position._next_close_seq()
3692
+
3693
+ # Add order to position (this will handle orderbook and exit_orders)
3694
+ position._add_order(order)
3695
+ # Same-tick fill is a backtest concept; in broker mode the order is already
3696
+ # enqueued by ``_add_order`` and the sync engine forwards it to the exchange.
3697
+ if immediately and isinstance(position, SimPosition):
3698
+ # Deferred immediate settle: fill after the body (settle_immediate_closes)
3699
+ # so position series stay at their pre-close values for the rest of the
3700
+ # bar — matching TradingView and PyneCore's broker mode.
3701
+ position._deferred_immediate_closes.append(order)
3702
+
3703
+
3704
+ def convert_to_account(value: PyneFloat) -> PyneFloat:
3705
+ """
3706
+ Converts a value from the symbol's quote currency to strategy.account_currency.
3707
+
3708
+ PyneCore runs single-currency: the account currency IS the symbol's quote
3709
+ currency (there is no FX conversion layer — see request.currency_rate, which
3710
+ returns na for the same reason), so the rate is always 1 and the value passes
3711
+ through unchanged. Kept so scripts using the TradingView idiom run.
3712
+
3713
+ :param value: A value expressed in the symbol's currency
3714
+ :return: The same value, expressed in the account currency
3715
+ """
3716
+ return value
3717
+
3718
+
3719
+ def convert_to_symbol(value: PyneFloat) -> PyneFloat:
3720
+ """
3721
+ Converts a value from strategy.account_currency to the symbol's quote currency.
3722
+
3723
+ The inverse of convert_to_account, and identity for the same reason: PyneCore
3724
+ is single-currency, so no rate is applied.
3725
+
3726
+ :param value: A value expressed in the account currency
3727
+ :return: The same value, expressed in the symbol's currency
3728
+ """
3729
+ return value
3730
+
3731
+
3732
+ # noinspection PyProtectedMember
3733
+ def _default_entry_budget(price: float) -> tuple[float, float] | None:
3734
+ """Money amount and per-unit cost of a default-sized entry at ``price``.
3735
+
3736
+ Returns ``(money, unit_cost)`` so that the raw quantity is
3737
+ ``money / unit_cost``, or None for fixed sizing (not money-based).
3738
+ """
3739
+ script = lib._script
3740
+ default_qty_type = script.default_qty_type
3741
+ if default_qty_type == fixed:
3742
+ return None
3743
+
3744
+ if default_qty_type == percent_of_equity:
3745
+ target_investment = script.position.equity * script.default_qty_value * 0.01
3746
+ if script.commission_type == _commission.percent:
3747
+ commission_multiplier = 1.0 + script.commission_value * 0.01
3748
+ return target_investment, price * syminfo.pointvalue * commission_multiplier
3749
+ if script.commission_type == _commission.cash_per_contract:
3750
+ return target_investment, price * syminfo.pointvalue + script.commission_value
3751
+ if script.commission_type == _commission.cash_per_order:
3752
+ return (max(0.0, target_investment - script.commission_value),
3753
+ price * syminfo.pointvalue)
3754
+ # No commission
3755
+ return target_investment, price * syminfo.pointvalue
3756
+
3757
+ if default_qty_type == cash:
3758
+ return script.default_qty_value, price * syminfo.pointvalue
3759
+
3760
+ raise ValueError("Unknown default qty type: ", default_qty_type)
3761
+
3762
+
3763
+ # noinspection PyProtectedMember
3764
+ def _default_entry_qty(price: float) -> float:
3765
+ """Contracts a default-sized (no explicit ``qty``) entry buys at ``price``.
3766
+
3767
+ TradingView calculates the position size so that the total investment
3768
+ (position value + commission) equals the specified percentage of equity:
3769
+
3770
+ - percent commission: ``total_cost = qty * price * (1 + commission_rate)``
3771
+ - cash per contract: ``total_cost = qty * price + qty * commission_value``
3772
+
3773
+ We want ``total_cost = equity * percent``, so
3774
+ ``qty = (equity * percent) / (price * (1 + commission_factor))``.
3775
+
3776
+ The price-based types (percent_of_equity, cash) resolve when the order
3777
+ EXECUTES — the caller passes the actual fill price at fill time, and only
3778
+ an executable-price estimate at placement (for margin checks).
3779
+ """
3780
+ budget = _default_entry_budget(price)
3781
+ if budget is None:
3782
+ return lib._script.default_qty_value
3783
+ money, unit_cost = budget
3784
+ return money / unit_cost
3785
+
3786
+
3787
+ # noinspection PyShadowingNames
3788
+ def default_entry_qty(price: float) -> float:
3789
+ """
3790
+ The quantity of contracts/shares/lots/units a default-sized entry
3791
+ (``strategy.entry`` without an explicit ``qty``) would buy at ``price``,
3792
+ per the strategy's ``default_qty_type`` / ``default_qty_value``.
3793
+
3794
+ Public Pine API (``strategy.default_entry_qty``) over the internal
3795
+ :func:`_default_entry_qty`.
3796
+
3797
+ :param price: The price the entry would execute at
3798
+ :return: The default order size in contracts
3799
+ """
3800
+ return _default_entry_qty(price)
3801
+
3802
+
3803
+ # Distance threshold (in ticks) of the big-money gate's down-step: an
3804
+ # inflated threshold landing on an even grid multiple steps down one grid
3805
+ # unit only when it cleared the inflated cost by more than this. Bracketed
3806
+ # in (0.0783, 0.1034) ticks on TV probes; 3/32 is the binary-exact candidate.
3807
+ _GATE_DOWN_STEP_DELTA = 0.09375
3808
+
3809
+
3810
+ def _ceil_to_grid(value: float, grid: float) -> tuple[int, float]:
3811
+ """Exact smallest multiple of ``grid`` that is >= ``value``.
3812
+
3813
+ ``value / grid`` alone can round across an integer near a grid point; the
3814
+ correction loops re-check with ``k * grid`` products, which are exact for
3815
+ the tick grids (0.5, 5) and magnitudes (< 2^53) involved.
3816
+
3817
+ :param value: The value to quantize upward
3818
+ :param grid: The grid step
3819
+ :return: ``(k, k * grid)`` where ``k * grid`` is the quantized value
3820
+ """
3821
+ k = math.ceil(value / grid)
3822
+ while (k - 1) * grid >= value:
3823
+ k -= 1
3824
+ while k * grid < value:
3825
+ k += 1
3826
+ return k, k * grid
3827
+
3828
+
3829
+ def _price_has_odd_f32_offset(price: float) -> bool:
3830
+ """Whether ``price`` sits an odd number of float32-ULP/25 quanta above
3831
+ its float32 lower neighbour, within seven quanta.
3832
+
3833
+ TV's big-money gate inflates its cost threshold only on bars whose close
3834
+ has this float32 relationship (measured 38/38 on BINANCE:BTCUSDT 30m; in
3835
+ the [2^16, 2^17) binade the quantum is 1/32 tick). A close exactly
3836
+ representable in float32 (offset 0) does not inflate.
3837
+
3838
+ :param price: The bar close driving the gate
3839
+ :return: True when the odd-offset relationship holds
3840
+ """
3841
+ if price <= 0.0 or not math.isfinite(price):
3842
+ return False
3843
+ f32 = struct.unpack('<f', struct.pack('<f', price))[0]
3844
+ bits = struct.unpack('<I', struct.pack('<f', f32))[0]
3845
+ if f32 > price:
3846
+ bits -= 1
3847
+ f32 = struct.unpack('<f', struct.pack('<I', bits))[0]
3848
+ ulp = struct.unpack('<f', struct.pack('<I', bits + 1))[0] - f32
3849
+ if ulp <= 0.0 or not math.isfinite(ulp):
3850
+ return False
3851
+ quanta = (price - f32) * 25.0 / ulp
3852
+ k = round(quanta)
3853
+ return k % 2 == 1 and k <= 7 and abs(quanta - k) < 0.25
3854
+
3855
+
3856
+ def _gate_entry_lots(equity_ticks: float, lots: int, rfactor: float,
3857
+ unit_cost: float, mintick: float, price: float) -> int | None:
3858
+ """Judge an entry of ``lots`` lots against TV's big-money margin gate.
3859
+
3860
+ From 1e9 cost ticks upward TV quantizes the order cost onto a tick grid
3861
+ (0.5 tick, 5 ticks from 1e10 cost ticks) and compares the raw equity tick
3862
+ count against the quantized threshold:
3863
+
3864
+ - equity >= threshold: the entry fills as sized;
3865
+ - equity below threshold but at least the plain grid ceiling of the cost
3866
+ (possible only when the threshold was inflated): the entry is rejected;
3867
+ - equity below the plain grid ceiling: the parity of the grid multiple
3868
+ decides — even rejects, odd fills one lot less.
3869
+
3870
+ On odd-float32-offset bars (see :func:`_price_has_odd_f32_offset`) with
3871
+ price >= 1e5 the threshold is the grid ceiling of the cost inflated by
3872
+ 2^-31 relative; an inflated threshold landing on an EVEN grid multiple
3873
+ steps one grid unit down when it cleared the inflated cost by more than
3874
+ ``_GATE_DOWN_STEP_DELTA`` (never below the plain ceiling, and not when
3875
+ the cost sits exactly on the grid). Reverse-engineered on BINANCE:BTCUSDT
3876
+ 30m one-shot probes: 19,613 of 19,614 measurements reproduced, boundary
3877
+ decade 21/22 (below C 1e5 rare inflated bars exist whose slope selector
3878
+ is unmapped; they are treated as uninflated here).
3879
+
3880
+ :param equity_ticks: Raw equity tick count (equity / mintick)
3881
+ :param lots: Entry size in lot units
3882
+ :param rfactor: Lots per contract (``syminfo._size_round_factor``)
3883
+ :param unit_cost: Account-currency cost of one contract
3884
+ :param mintick: Tick size
3885
+ :param price: The bar close driving the gate (inflation selector)
3886
+ :return: Granted lot count (``lots`` or ``lots - 1``) or None when the
3887
+ entry is rejected
3888
+ """
3889
+ cost = lots / rfactor * unit_cost / mintick
3890
+ grid = 5.0 if cost >= 1e10 else 0.5
3891
+ k0, m0 = _ceil_to_grid(cost, grid)
3892
+ m_eff = m0
3893
+ if price >= 1e5 and _price_has_odd_f32_offset(price):
3894
+ inflated = cost * (1.0 + 2.0 ** -31)
3895
+ k_eff, m_eff = _ceil_to_grid(inflated, grid)
3896
+ if k_eff % 2 == 0 and m_eff - inflated > _GATE_DOWN_STEP_DELTA:
3897
+ down = m_eff - grid
3898
+ if not (down == m0 == cost):
3899
+ m_eff = max(m0, down)
3900
+ if equity_ticks >= m_eff:
3901
+ return lots
3902
+ if equity_ticks >= m0:
3903
+ return None
3904
+ if k0 % 2 == 0:
3905
+ return None
3906
+ return lots - 1
3907
+
3908
+
3909
+ # noinspection PyProtectedMember
3910
+ def _judge_money_entry(size: float, price: float, market: bool = False) -> float:
3911
+ """Apply TV's big-money sizing and margin gate to a money-sized entry.
3912
+
3913
+ From 1e7 account-currency units of order money upward (equivalently 1e9
3914
+ ticks at mintick 0.01; the gate is bracketed in (9.0e6, 1.01e7] and is
3915
+ indistinguishable between the two at mintick 0.01) TV re-judges the
3916
+ floor-sized quantity: when the truncated money tick count reaches one
3917
+ grid unit below the NEXT lot's quantized cost, the gate is evaluated at
3918
+ that larger size (which its own cost then always exceeds, so the outcome
3919
+ is the parity branch: reject or fill the floor size); otherwise the gate
3920
+ runs at the floor size directly. See :func:`_gate_entry_lots` for the
3921
+ gate itself and the measurement provenance.
3922
+
3923
+ Below 1e7 money TV still snaps a MARKET entry up to the next lot when
3924
+ the raw money tick count reaches the grid floor-cell of that lot's
3925
+ cost (edge = cost ticks mod grid; unlike the >=1e7 gate the money side
3926
+ is NOT truncated to whole ticks). The grid is scale-dependent and only
3927
+ measured in bands; the snap applies only inside a verified band and
3928
+ only for market entries (the placement-close sizing path); everywhere
3929
+ else the plain floor stands. Measured by one-shot equity-injection
3930
+ sweeps on BINANCE:BTCUSDT 30m:
3931
+ - grid 0.05 at cost [1e8, 1.16e8] ticks (2026-07-08): edges two-sided
3932
+ at cost 1.0200e8 and 1.1575e8 (5-level cluster), further ON points at
3933
+ 1.005e8/1.08e8, OFF at 9.9e7 and from 1.20e8 up (with an unmapped
3934
+ interleaved ON at 1.25e8 — the OFF points are consistent with a
3935
+ different, unmapped grid rather than an inactive mechanism).
3936
+ - grid 0.005 at cost ~1.245e7 ticks (2026-07-10, the Fabio Pro Scalper
3937
+ 2025-11-05 10:30 razor cancel): edge pinned exactly at money ticks
3938
+ 12451249.295 = ceil_.005(cost) - 0.005 by 7-probe bisection (fill at
3939
+ .294/.2949, cancel at .29501/.2955/.29711); grids 0.05/0.01/0.002
3940
+ are each refuted by one of those points. Band held at [1.2e7, 1.3e7]
3941
+ until more levels are mapped.
3942
+ A snapped size then faces the ordinary
3943
+ creation-time margin check at the placement close: at 100%
3944
+ percent_of_equity sizing the snapped cost always exceeds equity, so the
3945
+ entry cancels at placement even when the fill open would fit (measured:
3946
+ the Gaussian Channel razor cancel and the 2025-01-02 19:30 flat100 probe
3947
+ cancel, where the open HAD gapped down far enough) — which is how the
3948
+ Gaussian Channel corpus divergence resolves.
3949
+
3950
+ :param size: Signed floor-sized quantity in contracts
3951
+ :param price: The sizing/gate price (placement close for market entries,
3952
+ fill price for price-based orders resolving at execution)
3953
+ :param market: True when judging a market entry at placement (enables
3954
+ the sub-1e7 snap-up; price-based fills keep the plain floor)
3955
+ :return: The granted signed quantity, or 0.0 when the entry is rejected
3956
+ """
3957
+ budget = _default_entry_budget(price)
3958
+ if budget is None:
3959
+ return size
3960
+ money, unit_cost = budget
3961
+ mintick = syminfo.mintick
3962
+ if not mintick or mintick <= 0:
3963
+ return size
3964
+ rfactor = syminfo._size_round_factor # noqa
3965
+ lots = round(abs(size) * rfactor)
3966
+ if lots <= 0:
3967
+ return size
3968
+ if money < 1e7:
3969
+ if not market:
3970
+ return size
3971
+ next_cost = (lots + 1) / rfactor * unit_cost / mintick
3972
+ if 1e8 <= next_cost <= 1.16e8:
3973
+ snap_grid = 0.05
3974
+ elif 1.2e7 <= next_cost <= 1.3e7:
3975
+ snap_grid = 0.005
3976
+ else:
3977
+ return size
3978
+ _, m1 = _ceil_to_grid(next_cost, snap_grid)
3979
+ # m1 - grid unconditionally, like the >=1e7 snap: a cost landing
3980
+ # exactly on the grid keeps the full 0.05 window (the 2025-01-02
3981
+ # 19:30 flat100 cancel pinned this — cost double == grid double,
3982
+ # TV still snapped).
3983
+ if money / mintick >= m1 - snap_grid:
3984
+ sign = 1.0 if size > 0 else -1.0
3985
+ return sign * (lots + 1) / rfactor
3986
+ return size
3987
+ money_ticks = money / mintick
3988
+ next_cost = (lots + 1) / rfactor * unit_cost / mintick
3989
+ next_grid = 5.0 if next_cost >= 1e10 else 0.5
3990
+ _, next_m0 = _ceil_to_grid(next_cost, next_grid)
3991
+ if math.floor(money_ticks) >= next_m0 - next_grid:
3992
+ lots += 1
3993
+ granted = _gate_entry_lots(money_ticks, lots, rfactor, unit_cost, mintick, price)
3994
+ if granted is None or granted <= 0:
3995
+ return 0.0
3996
+ sign = 1.0 if size > 0 else -1.0
3997
+ return sign * granted / rfactor
3998
+
3999
+
4000
+ # noinspection PyProtectedMember,PyShadowingNames,PyShadowingBuiltins,DuplicatedCode
4001
+ def entry(id: str, direction: direction.Direction, qty: int | PyneFloat = na_float,
4002
+ limit: int | float | None = None, stop: int | float | None = None,
4003
+ oca_name: str | None = None, oca_type: _oca.Oca | None = None,
4004
+ comment: str | None = None, alert_message: str | None = None):
4005
+ """
4006
+ Creates a new order to open or add to a position. If an order with the same id already exists
4007
+ and is unfilled, this command will modify that order.
4008
+
4009
+ :param id: The identifier of the order
4010
+ :param direction: The direction of the order (long or short)
4011
+ :param qty: The number of contracts/lots/shares/units to buy or sell
4012
+ :param limit: The price at which the order is filled
4013
+ :param stop: The price at which the order is filled
4014
+ :param oca_name: The name of the order cancel/replace group
4015
+ :param oca_type: The type of the order cancel/replace group
4016
+ :param comment: Additional notes on the filled order
4017
+ :param alert_message: Custom text for the alert that fires when an order fills
4018
+ """
4019
+ if lib._lib_semaphore or lib._strategy_suppressed:
4020
+ return
4021
+
4022
+ script = lib._script
4023
+ position = script.position
4024
+
4025
+ # Risk management: Check if trading is halted
4026
+ if position.risk_halt_trading:
4027
+ return
4028
+
4029
+ # Intraday-cap freeze gate: once ``strategy.risk.max_intraday_filled_orders``
4030
+ # is reached for the current day, TradingView blocks all subsequent entry
4031
+ # placements until the next trading day. Dropping only the fill is not
4032
+ # enough — an entry placed on a latched bar would survive the day rollover
4033
+ # and fire a phantom entry at the new day's open, where the counter has
4034
+ # already reset. Block the placement itself, matching TV's broker emulator.
4035
+ if position._is_intraday_filled_cap_reached():
4036
+ return
4037
+
4038
+ # We need a signed size instead of qty, the sign is the direction
4039
+ direction_sign: float = (-1.0 if direction == short else 1.0)
4040
+
4041
+ if (isinstance(limit, NA) or limit != limit):
4042
+ limit = None
4043
+ elif limit is not None:
4044
+ # We need negative direction for entry limit orders - NOTE: it is tested
4045
+ limit = _price_round(limit, -direction_sign)
4046
+ if (isinstance(stop, NA) or stop != stop):
4047
+ stop = None
4048
+ elif stop is not None:
4049
+ stop = _price_round(stop, direction_sign)
4050
+
4051
+ # A default-sized (no explicit qty) price-based order resolves its
4052
+ # quantity at the actual fill price; for those the size computed here is
4053
+ # only the placement estimate used for the margin check and order
4054
+ # bookkeeping. A MARKET entry keeps this size: TV sizes it from the
4055
+ # mark-to-market equity and close of the placement bar (probe-verified on
4056
+ # BINANCE:BTCUSDT 30m: flat entries 13281/13281, reversal flips
4057
+ # 26560/26561 exact). The sizing price is the price the order would
4058
+ # execute at NOW — the current price when immediately executable, the
4059
+ # limit/stop price while it rests.
4060
+ deferred_default = (isinstance(qty, NA) or qty != qty)
4061
+ market_sizing_price: float | None = None
4062
+ if deferred_default:
4063
+ exec_price = position.c
4064
+ if limit is not None:
4065
+ exec_price = min(limit, exec_price) if direction_sign > 0 else max(limit, exec_price)
4066
+ elif stop is not None:
4067
+ exec_price = max(stop, exec_price) if direction_sign > 0 else min(stop, exec_price)
4068
+ else:
4069
+ market_sizing_price = float(exec_price)
4070
+ qty = _default_entry_qty(exec_price)
4071
+
4072
+ # qty must be greater than 0. Written as `not (qty > 0.0)` so a NaN qty is
4073
+ # also skipped: default-sizing (`_default_entry_qty`) returns NaN when the
4074
+ # sizing price is NaN (e.g. an entry evaluated before a valid price exists),
4075
+ # and `qty <= 0.0` is False for NaN — which let NaN flow into `_size_round`
4076
+ # / `_judge_money_entry` and raised `cannot convert float NaN to integer`.
4077
+ # TradingView places no order when the default size cannot be computed.
4078
+ if not (qty > 0.0):
4079
+ return
4080
+
4081
+ size = qty * direction_sign
4082
+
4083
+ # The Pine-side lot floor is a backtest-only quantization: TV silently
4084
+ # snaps a sub-lot size to zero and drops the order. In broker mode the
4085
+ # exchange owns the quantity grid — the plugin quantizes onto the venue
4086
+ # step and emits an explicit below-minimum skip. Flooring here would
4087
+ # instead drop a below-grid signal silently, before the order is ever
4088
+ # built, hiding an invalid live signal from the operator. Keep the raw
4089
+ # requested qty in broker mode so the sync engine dispatches it and the
4090
+ # plugin's quantity preflight reports the skip.
4091
+ if isinstance(position, SimPosition):
4092
+ size = _size_round(size)
4093
+ if size == 0.0:
4094
+ return
4095
+
4096
+ # Market entries keep their placement-close sizing (price-based orders
4097
+ # re-resolve at fill), so the big-money sizing gate is judged here. A
4098
+ # sub-1e7 snapped-up size deliberately falls through to the creation-time
4099
+ # margin check below: TV cancels a snapped entry whose snapped cost can
4100
+ # no longer be margined at the placement close even when the fill open
4101
+ # would permit it (measured: the Gaussian Channel razor cancel at
4102
+ # 100% sizing, and the 2025-01-02 19:30 flat100 probe cancel where the
4103
+ # open HAD gapped down far enough to fit).
4104
+ if market_sizing_price is not None:
4105
+ size = _judge_money_entry(float(size), market_sizing_price, market=True)
4106
+ if size == 0.0:
4107
+ if position.size == 0.0 or position.sign == direction_sign:
4108
+ return
4109
+ # A nofill judgment does not cancel a reversal outright: TV keeps
4110
+ # the order alive as its closing leg, so the opposite position
4111
+ # still closes at the next open while the opening leg stays
4112
+ # suppressed (Hybrid 2026-05-14 15:00: the short closes at the
4113
+ # bar open, the long only fills a bar later from the re-issued,
4114
+ # re-judged entry). The zero size nets to a pure close through
4115
+ # the reversal flip at order processing.
4116
+ order = Order(id, 0.0, order_type=_order_type_entry, oca_name=oca_name,
4117
+ oca_type=oca_type, comment=comment, alert_message=alert_message)
4118
+ order.sign = direction_sign
4119
+ position._add_order(order)
4120
+ return
4121
+
4122
+ # Creation-time margin check for entry orders (TradingView backtest behavior).
4123
+ # TV cancels an entry order it cannot open: required margin is evaluated at
4124
+ # the CURRENT price (the "LastPrice" of its margin formula), with the order
4125
+ # sized at the price it would execute at now. A resting buy limit below the
4126
+ # market at 100% percent_of_equity sizing therefore never opens (required =
4127
+ # equity * price / limit > equity), while a resting sell limit above the
4128
+ # market and any immediately executable order fit within equity.
4129
+ # Skip in broker mode: the exchange enforces margin authoritatively, and the script's
4130
+ # equity view can drift from the exchange (funding, fees, transfers) — making the
4131
+ # local check a source of silent false positives rather than a safety net.
4132
+ if isinstance(position, SimPosition):
4133
+ margin_percent = (script.margin_short if direction_sign < 0
4134
+ else script.margin_long)
4135
+ if margin_percent > 0:
4136
+ margin_ratio = margin_percent / 100.0
4137
+ if limit is None and stop is None:
4138
+ slippage_amount = script.slippage * syminfo.mintick
4139
+ check_price = position.c + slippage_amount * direction_sign
4140
+ else:
4141
+ check_price = position.c
4142
+ equity = script.initial_capital + position.netprofit + position.openprofit
4143
+ # Margin/equity are in account currency — convert via pointvalue.
4144
+ margin_needed = abs(size) * check_price * syminfo.pointvalue * margin_ratio
4145
+ # From 1e7 account-currency units of equity upward TV runs this
4146
+ # creation-time check as the quantized big-money gate (see
4147
+ # _gate_entry_lots): the order is cancelled unless the equity tick
4148
+ # count reaches the grid threshold of the required margin. A
4149
+ # money-sized market entry already passed _judge_money_entry, and
4150
+ # its granted cost always clears this equity-side gate at 100%
4151
+ # margin; explicit-qty and resting orders are judged here (the
4152
+ # placement estimate — price-based orders re-size at fill).
4153
+ # Measured on BINANCE:BTCUSDT 30m: Hybrid 2025-08-25 05:00
4154
+ # (equity 18.58M, surplus 0.19 tick) was rejected on TV and
4155
+ # refilled one bar later; MAB corpus entries at 1.06M/1.32M
4156
+ # equity fill despite the same tick geometry, bracketing the gate
4157
+ # below 1e7 together with the sizing-law gate in (9.0e6, 1.01e7].
4158
+ mintick = syminfo.mintick
4159
+ if equity >= 1e7 and mintick and mintick > 0:
4160
+ rfactor = syminfo._size_round_factor # noqa
4161
+ lots = round(abs(size) * rfactor)
4162
+ unit_margin = check_price * syminfo.pointvalue * margin_ratio
4163
+ if lots > 0:
4164
+ granted = _gate_entry_lots(equity / mintick, lots, rfactor,
4165
+ unit_margin, mintick, check_price)
4166
+ if granted != lots:
4167
+ return
4168
+ elif margin_needed > equity:
4169
+ return
4170
+
4171
+ # If it is not a market order, we should check pyramiding and flip conditions here
4172
+ # Market orders are checked at the order processing time
4173
+ flip_extra = 0.0
4174
+ if limit is not None or stop is not None:
4175
+ # Check if the order has the same direction
4176
+ if position.sign == direction_sign:
4177
+ # Check pyramiding limit for entry orders adding to existing position
4178
+ if lib._script.pyramiding <= len(position.open_trades):
4179
+ # Pyramiding limit reached - don't add the order
4180
+ return
4181
+
4182
+ elif position.size != 0.0:
4183
+ # TradingView calculates the flip quantity at order creation time,
4184
+ # not at execution time. If we have an opposite direction position,
4185
+ # we need to add the position size to the order size to flip it.
4186
+ # This means the order will first close the existing position,
4187
+ # then open a new one in the opposite direction.
4188
+ size -= position.size # Subtract because position.size has opposite sign
4189
+ flip_extra = abs(position.size)
4190
+
4191
+ order = Order(id, size, order_type=_order_type_entry, limit=limit, stop=stop, oca_name=oca_name,
4192
+ oca_type=oca_type, comment=comment, alert_message=alert_message)
4193
+ # Only price-based orders re-size at execution; a market entry keeps its
4194
+ # placement-time (signal close) quantity — TV rejects it at the next open
4195
+ # when that quantity can no longer be margined, rather than re-sizing.
4196
+ if deferred_default and (limit is not None or stop is not None):
4197
+ order.deferred_qty = True
4198
+ order.flip_extra = flip_extra
4199
+ # Store in entry_orders dict
4200
+ position._add_order(order)
4201
+
4202
+
4203
+ # noinspection PyShadowingBuiltins,PyProtectedMember,PyShadowingNames,PyUnusedLocal
4204
+ def exit(id: str, from_entry: str = "",
4205
+ qty: PyneFloat = na_float, qty_percent: PyneFloat = na_float,
4206
+ profit: PyneFloat = na_float, limit: PyneFloat = na_float,
4207
+ loss: PyneFloat = na_float, stop: PyneFloat = na_float,
4208
+ trail_price: PyneFloat = na_float, trail_points: PyneFloat = na_float,
4209
+ trail_offset: PyneFloat = na_float,
4210
+ oca_name: PyneStr = na_str, oca_type: _oca.Oca | None = None,
4211
+ comment: PyneStr = na_str, comment_profit: PyneStr = na_str,
4212
+ comment_loss: PyneStr = na_str, comment_trailing: PyneStr = na_str,
4213
+ alert_message: PyneStr = na_str, alert_profit: PyneStr = na_str,
4214
+ alert_loss: PyneStr = na_str, alert_trailing: PyneStr = na_str,
4215
+ disable_alert: bool = False):
4216
+ """
4217
+ Creates an order to exit from a position. If an order with the same id already exists and is unfilled,
4218
+
4219
+ :param id: The identifier of the order
4220
+ :param from_entry: The identifier of the entry order to close
4221
+ :param qty: The number of contracts/lots/shares/units to close when an exit order fills
4222
+ :param qty_percent: A value between 0 and 100 representing the percentage of the open trade quantity to close
4223
+ :param profit: The take-profit distance, expressed in ticks
4224
+ :param limit: The take-profit price
4225
+ :param loss: The stop-loss distance, expressed in ticks
4226
+ :param stop: The stop-loss price
4227
+ :param trail_price: The price of the trailing stop activation level
4228
+ :param trail_points: The trailing stop activation distance, expressed in ticks
4229
+ :param trail_offset: The trailing stop offset
4230
+ :param oca_name: The name of the order cancel/replace group
4231
+ :param oca_type: The type of the order cancel/replace group
4232
+ :param comment: Additional notes on the filled order
4233
+ :param comment_profit: Additional notes on the filled order
4234
+ :param comment_loss: Additional notes on the filled order
4235
+ :param comment_trailing: Additional notes on the filled order
4236
+ :param alert_message: Custom text for the alert that fires when an order fills
4237
+ :param alert_profit: Custom text for the alert that fires when an order fills
4238
+ :param alert_loss: Custom text for the alert that fires when an order fills
4239
+ :param alert_trailing: Custom text for the alert that fires when an order fills
4240
+ :param disable_alert: If true, the alert will not fire when the order fills
4241
+ """
4242
+ if lib._lib_semaphore or lib._strategy_suppressed:
4243
+ return
4244
+
4245
+ script = lib._script
4246
+ position = script.position
4247
+
4248
+ if qty < 0.0:
4249
+ return
4250
+
4251
+ direction = 0
4252
+ size = 0.0
4253
+ init_size = 0.0
4254
+
4255
+ # noinspection PyProtectedMember,PyShadowingNames
4256
+ def _exit():
4257
+ nonlocal limit, stop, trail_price, from_entry, direction, size, oca_name, oca_type
4258
+
4259
+ # Sticky bracket (TV semantics): a leg is identified by (id, from_entry).
4260
+ # Re-issuing it every bar updates its prices, but a leg that already fired
4261
+ # its slice must not be resurrected (the ``consumed`` tombstone). The
4262
+ # reservation is recomputed from ``init_size`` on every issue: that is the
4263
+ # ORIGINAL size of everything bound to ``from_entry`` — open pyramid adds
4264
+ # at their entry size plus a still-pending entry order at its CURRENT
4265
+ # size — so a pyramid add grows the slice, margin-call shrinkage does not
4266
+ # erode it, and a pending entry re-sized bar-to-bar keeps being tracked
4267
+ # (locking the first bar's size would under-close the eventual fill and
4268
+ # strand a sliver).
4269
+ exit_key = (id, from_entry)
4270
+ existing = position.exit_orders.get(exit_key)
4271
+ if existing is not None and existing.consumed:
4272
+ return
4273
+
4274
+ is_rest_leg = (isinstance(qty, NA) or qty != qty) and (isinstance(qty_percent, NA) or qty_percent != qty_percent)
4275
+ # Sibling legs reserve slices of the entry first-come-first-served
4276
+ # (consumed siblings keep their reservation until the entry fully
4277
+ # closes). Only sticky exit legs (book_seq is None) count as siblings;
4278
+ # a stacked strategy.close()/close_all() partial (book_seq set) is an
4279
+ # immediate market close, not a reservation against this leg.
4280
+ sibling = sum(o.reserved_size for o in position.exit_orders.values()
4281
+ if o.order_id == from_entry and o is not existing
4282
+ and o.book_seq is None)
4283
+ unreserved = abs(init_size) - sibling
4284
+ # A qty/qty_percent leg is capped at the unreserved remainder --
4285
+ # TradingView never lets a later exit call take a slice a pre-existing
4286
+ # leg already holds. Verified on live TV (BINANCE:BTCUSDT 30m probes):
4287
+ # a late qty_percent=50 or qty=1 leg issued while a no-qty stop leg
4288
+ # holds 100% never creates an order (553/553 cycles), and against a
4289
+ # qty_percent=75 stop leg the same call is reduced to the remaining
4290
+ # 25% instead of being dropped.
4291
+ if not (isinstance(qty, NA) or qty != qty):
4292
+ reserved = min(abs(qty), unreserved)
4293
+ elif not (isinstance(qty_percent, NA) or qty_percent != qty_percent):
4294
+ reserved = min(abs(init_size) * (qty_percent * 0.01), unreserved)
4295
+ else:
4296
+ # No-qty "rest" leg: the whole unreserved remainder, so it never
4297
+ # over-closes the position.
4298
+ reserved = unreserved
4299
+
4300
+ reserved = _size_round(reserved)
4301
+ if reserved <= 0.0:
4302
+ return
4303
+ size = -direction * reserved
4304
+
4305
+ # Store tick values for later calculation when entry price is known
4306
+ profit_ticks: float | None = _na_to_none(profit)
4307
+ loss_ticks: float | None = _na_to_none(loss)
4308
+ trail_points_ticks: float | None = _na_to_none(trail_points)
4309
+ # TradingView truncates a fractional ``trail_offset`` tick count to
4310
+ # whole ticks (like its qty precision). Verified against a TV
4311
+ # reference (BINANCE:BTCUSDT 30m, ``trail_points=trail_offset=
4312
+ # atr*mult``): TV's trailing fills land at ``water mark -/+
4313
+ # floor(offset_ticks) * mintick``, while fractional ticks would round
4314
+ # half the fills one tick further. ``trail_points`` stays fractional:
4315
+ # the activation price resolves with directional tick-rounding
4316
+ # (bracket trail probe 91, ``trail_points=atr``, matches TV that way).
4317
+ _trail_offset = _na_to_none(trail_offset)
4318
+ if _trail_offset is not None:
4319
+ _trail_offset = float(int(_trail_offset))
4320
+ _trail_price = _na_to_none(trail_price)
4321
+
4322
+ # A missing ``trail_offset`` does NOT disable the trailing leg. TradingView's
4323
+ # compile rule only requires the offset when the trailing pair is the
4324
+ # exit's SOLE trigger; alongside ``stop``/``limit`` the call compiles, and the
4325
+ # TV reference exports (pynecomp bracket trail probes 88-91) prove the trailing
4326
+ # stop arms with an offset of 0 ticks. The offset-0 default is applied at
4327
+ # ``Order`` construction.
4328
+
4329
+ # An exit must arm at least one trigger. TradingView treats a call whose
4330
+ # price/tick args ALL resolve to na as a no-op -- e.g. brackets computed
4331
+ # from a flat position_avg_price (na) on a bar before the entry fills --
4332
+ # not a level-less market close that fires at the next open.
4333
+ if ((isinstance(limit, NA) or limit != limit) and (isinstance(stop, NA) or stop != stop) and (isinstance(profit, NA) or profit != profit)
4334
+ and (isinstance(loss, NA) or loss != loss) and _trail_price is None
4335
+ and trail_points_ticks is None):
4336
+ return
4337
+
4338
+ _limit = _na_to_none(limit)
4339
+ if _limit is not None:
4340
+ _limit = _price_round(_limit, direction)
4341
+ _stop = _na_to_none(stop)
4342
+ if _stop is not None:
4343
+ _stop = _price_round(_stop, -direction)
4344
+ if _trail_price is not None:
4345
+ _trail_price = _price_round(_trail_price, -direction)
4346
+
4347
+ # Default OCA settings for strategy.exit() - matches TradingView behavior
4348
+ # If no oca_name is specified, create a default OCA reduce group
4349
+ if isinstance(oca_name, NA):
4350
+ # Use a unique name based on the exit id and from_entry
4351
+ oca_name = f"__exit_{id}_{from_entry}_oca__"
4352
+ # Default to reduce type (TradingView behavior)
4353
+ oca_type = _oca.reduce
4354
+ else:
4355
+ # If oca_name is provided but no type, default to reduce
4356
+ if oca_type is None:
4357
+ oca_type = _oca.reduce
4358
+
4359
+ # Add order
4360
+ order = Order(
4361
+ from_entry, size, exit_id=id, order_type=_order_type_close,
4362
+ limit=_limit, stop=_stop,
4363
+ trail_price=_trail_price, trail_offset=_trail_offset,
4364
+ profit_ticks=profit_ticks, loss_ticks=loss_ticks, trail_points_ticks=trail_points_ticks,
4365
+ oca_name=_na_to_none(oca_name), oca_type=oca_type,
4366
+ comment=_na_to_none(comment),
4367
+ alert_message=_na_to_none(alert_message),
4368
+ comment_profit=_na_to_none(comment_profit),
4369
+ comment_loss=_na_to_none(comment_loss),
4370
+ comment_trailing=_na_to_none(comment_trailing),
4371
+ alert_profit=_na_to_none(alert_profit),
4372
+ alert_loss=_na_to_none(alert_loss),
4373
+ alert_trailing=_na_to_none(alert_trailing)
4374
+ )
4375
+
4376
+ # Sticky bracket (TV semantics): a re-issued live trailing leg keeps its
4377
+ # activated high/low-water mark ONLY when the trailing parameters are
4378
+ # unchanged. TradingView carries ONE logical trailing stop across
4379
+ # identical re-issues -- a fresh Order must inherit the ratcheted
4380
+ # ``trail_stop`` instead of re-arming at the bare activation level every
4381
+ # bar, which would leave the stop permanently one or more bars behind
4382
+ # the carried water mark. A re-issue with CHANGED trailing parameters
4383
+ # (a per-bar recomputed atr-based trail, a stricter activation rebased
4384
+ # on a pyramid add, ...) is a cancel+replace: the armed state and the
4385
+ # carried water mark are dropped and the replaced leg re-arms from the
4386
+ # issue bar's CLOSE tick (see ``_seed_trail_at_issue``); the prior
4387
+ # bars' extremes stay out of its water mark. Verified against a TV
4388
+ # reference (BINANCE:BTCUSDT 30m, per-bar ``trail_points=atr*mult``):
4389
+ # TV's re-armed stop anchored to the issue bar's close instead of
4390
+ # carrying the prior high-water mark. The activation is compared in
4391
+ # the form it was given -- ``existing.trail_price`` may hold a
4392
+ # points-resolved value, so the entry-anchored ``trail_points`` form
4393
+ # compares tick counts.
4394
+ had_trail = False
4395
+ trail_unchanged = False
4396
+ if existing is not None and (
4397
+ existing.trail_price is not None or existing.trail_points_ticks is not None):
4398
+ had_trail = True
4399
+ trail_unchanged = (
4400
+ existing.trail_offset == order.trail_offset
4401
+ and ((order.trail_points_ticks is not None
4402
+ and existing.trail_points_ticks == order.trail_points_ticks)
4403
+ or (order.trail_points_ticks is None
4404
+ and existing.trail_points_ticks is None
4405
+ and existing.trail_price == order.trail_price)))
4406
+ if trail_unchanged and existing.trail_triggered:
4407
+ order.trail_triggered = True
4408
+ order.trail_stop = existing.trail_stop
4409
+
4410
+ order.rest_leg = is_rest_leg
4411
+ position._add_order(order)
4412
+ # A brand-new trailing leg (first issue, or trailing added to a live
4413
+ # bracket) and an identical re-issue fold the issue bar's extreme into
4414
+ # the water mark; a changed-params re-issue re-arms anchored to the
4415
+ # issue bar's close only (see above).
4416
+ position._seed_trail_at_issue(order, fold_extreme=not had_trail or trail_unchanged)
4417
+
4418
+ def _bound_size(entry_id: str) -> tuple[float, float]:
4419
+ """Combined sign and ORIGINAL size of everything bound to an entry id:
4420
+ open pyramid adds at their entry size plus a still-pending entry order at
4421
+ its current size. TradingView's exit covers each of them, so the leg is
4422
+ reserved off the combined size and the FIFO fill allocation then closes
4423
+ the bound trades the way TV's per-entry exit brackets do."""
4424
+ sign = 0.0
4425
+ total = 0.0
4426
+ pending = position.entry_orders.get(entry_id)
4427
+ if pending is not None:
4428
+ sign = pending.sign
4429
+ # Only the not-yet-filled remainder of the entry order counts. The
4430
+ # backtest simulator removes a market entry order on fill, so
4431
+ # ``filled_qty`` stays 0.0 and this is simply ``abs(pending.size)``.
4432
+ # The live broker keeps the entry Order in ``entry_orders`` for
4433
+ # intent stability while ``record_fill`` moves the filled slice into
4434
+ # ``open_trades``; counting the full order size there would
4435
+ # double-count the fill and over-reserve the exit (issue BYBIT-001).
4436
+ unfilled = abs(pending.size) - pending.filled_qty
4437
+ if unfilled > 0.0:
4438
+ total += unfilled
4439
+ for open_trade in position.open_trades:
4440
+ if open_trade.entry_id == entry_id:
4441
+ sign = open_trade.sign
4442
+ total += abs(open_trade.init_size)
4443
+ return sign, total
4444
+
4445
+ # Find direction and size
4446
+ if from_entry:
4447
+ direction, init_size = _bound_size(from_entry)
4448
+ # The position should be open, or an entry order should exist
4449
+ if not direction:
4450
+ return
4451
+ _exit()
4452
+
4453
+ else:
4454
+ # If still no entry order found, we should exit all open trades and open orders
4455
+ if not direction:
4456
+ for order in list(position.entry_orders.values()):
4457
+ from_entry = order.order_id or ""
4458
+ direction, init_size = _bound_size(from_entry)
4459
+ # Only mark as from_entry_na on first creation (not replacement)
4460
+ exit_key = (id, from_entry)
4461
+ had_existing_exit = exit_key in position.exit_orders
4462
+ _exit()
4463
+ if not had_existing_exit:
4464
+ exit_order = position.exit_orders.get(exit_key)
4465
+ if exit_order is not None:
4466
+ exit_order.from_entry_na = True
4467
+
4468
+ if not direction:
4469
+ seen_ids: set[str] = set()
4470
+ for trade in position.open_trades:
4471
+ from_entry = trade.entry_id or ""
4472
+ if from_entry in seen_ids:
4473
+ continue
4474
+ seen_ids.add(from_entry)
4475
+ direction, init_size = _bound_size(from_entry)
4476
+ _exit()
4477
+
4478
+
4479
+ # noinspection PyProtectedMember,PyShadowingNames,PyShadowingBuiltins,PyUnusedLocal,DuplicatedCode
4480
+ def order(id: str, direction: direction.Direction, qty: int | PyneFloat = na_float,
4481
+ limit: int | float | None = None, stop: int | float | None = None,
4482
+ oca_name: str | None = None, oca_type: _oca.Oca | None = None,
4483
+ comment: str | None = None, alert_message: str | None = None,
4484
+ disable_alert: bool = False):
4485
+ """
4486
+ Creates a new order to open, add to, or exit from a position. If an unfilled order with
4487
+ the same id exists, a call to this command modifies that order.
4488
+
4489
+ Unlike strategy.entry, orders from this command are not affected by the pyramiding parameter
4490
+ of the strategy declaration. Strategies can open any number of trades in the same direction
4491
+ with calls to this function.
4492
+
4493
+ This command does not automatically reverse open positions. For example, if there is an open
4494
+ long position of five shares, an order from this command with a qty of 5 and a direction
4495
+ of strategy.short triggers the sale of five shares, which closes the position.
4496
+
4497
+ :param id: The identifier of the order
4498
+ :param direction: The direction of the trade (strategy.long or strategy.short)
4499
+ :param qty: The number of contracts/shares/lots/units to trade when the order fills
4500
+ :param limit: The limit price of the order. With ``stop`` set too, the order becomes two OCA legs (a limit and a stop), not a single stop-limit order
4501
+ :param stop: The stop price of the order. With ``limit`` set too, the order becomes two OCA legs (a limit and a stop), not a single stop-limit order
4502
+ :param oca_name: The name of the One-Cancels-All (OCA) group
4503
+ :param oca_type: Specifies how an unfilled order behaves when another order in the same OCA group executes
4504
+ :param comment: Additional notes on the filled order
4505
+ :param alert_message: Custom text for the alert that fires when an order fills
4506
+ :param disable_alert: If true, the strategy does not trigger an alert when the order fills
4507
+ """
4508
+ if lib._lib_semaphore or lib._strategy_suppressed:
4509
+ return
4510
+
4511
+ script = lib._script
4512
+ position = script.position
4513
+
4514
+ # Risk management: Check if trading is halted
4515
+ # TODO: investigate if it should be checked here
4516
+ if position.risk_halt_trading:
4517
+ return
4518
+
4519
+ # We need a signed size instead of qty, the sign is the direction
4520
+ direction_sign: float = (-1.0 if direction == short else 1.0)
4521
+
4522
+ if (isinstance(limit, NA) or limit != limit):
4523
+ limit = None
4524
+ elif limit is not None:
4525
+ limit = _price_round(limit, direction_sign) # TODO: test this if the direction here is correct
4526
+ if (isinstance(stop, NA) or stop != stop):
4527
+ stop = None
4528
+ elif stop is not None:
4529
+ stop = _price_round(stop, -direction_sign) # TODO: test this if the direction here is correct
4530
+
4531
+ # A default-sized order resolves its quantity at the actual fill price
4532
+ # (TradingView sizes percent_of_equity / cash when the order executes).
4533
+ # The size computed here is the placement estimate, taken at the price the
4534
+ # order would execute at NOW — the current price when immediately
4535
+ # executable, the limit/stop price while it rests.
4536
+ deferred_default = (isinstance(qty, NA) or qty != qty)
4537
+ market_sizing_price: float | None = None
4538
+ if deferred_default:
4539
+ exec_price = float(lib.close)
4540
+ if limit is not None:
4541
+ exec_price = min(limit, exec_price) if direction_sign > 0 else max(limit, exec_price)
4542
+ elif stop is not None:
4543
+ exec_price = max(stop, exec_price) if direction_sign > 0 else min(stop, exec_price)
4544
+ else:
4545
+ market_sizing_price = exec_price
4546
+ qty = _default_entry_qty(exec_price)
4547
+
4548
+ # qty must be greater than 0. Written as `not (qty > 0.0)` so a NaN qty is
4549
+ # also skipped: default-sizing (`_default_entry_qty`) returns NaN when the
4550
+ # sizing price is NaN (e.g. an entry evaluated before a valid price exists),
4551
+ # and `qty <= 0.0` is False for NaN — which let NaN flow into `_size_round`
4552
+ # / `_judge_money_entry` and raised `cannot convert float NaN to integer`.
4553
+ # TradingView places no order when the default size cannot be computed.
4554
+ if not (qty > 0.0):
4555
+ return
4556
+
4557
+ size = qty * direction_sign
4558
+
4559
+ # NOTE: Unlike strategy.entry, strategy.order is NOT affected by pyramiding limit
4560
+ # This is a key difference - strategy.order can open unlimited trades in the same direction
4561
+ # It uses _order_type_normal to distinguish it from entry/exit orders
4562
+
4563
+ size = _size_round(size)
4564
+ if size == 0.0:
4565
+ return
4566
+
4567
+ # Market orders keep their placement-close sizing (price-based orders
4568
+ # re-resolve at fill), so the big-money sizing gate is judged here.
4569
+ if market_sizing_price is not None:
4570
+ size = _judge_money_entry(float(size), market_sizing_price)
4571
+ if size == 0.0:
4572
+ return
4573
+
4574
+ # Create the order with _order_type_normal
4575
+ # This is a "normal" order that simply adds to or subtracts from position
4576
+ # It doesn't follow entry/exit rules and can freely modify positions
4577
+ order = Order(id, size, order_type=_order_type_normal, limit=limit, stop=stop,
4578
+ oca_name=oca_name, oca_type=oca_type, comment=comment,
4579
+ alert_message=alert_message)
4580
+ # Only price-based orders re-size at execution (see strategy.entry)
4581
+ if deferred_default and (limit is not None or stop is not None):
4582
+ order.deferred_qty = True
4583
+ position._add_order(order)
4584
+
4585
+
4586
+ #
4587
+ # Properties
4588
+ #
4589
+
4590
+ # Strategy state accessors below return inert defaults when invoked in a
4591
+ # security child process: there `lib._script` is None because no
4592
+ # ScriptRunner.run_iter() ever ran. Pine itself rejects strategy.* state
4593
+ # reads inside any request.*() argument at compile time (CE10059), so the
4594
+ # values are never consumed by the chart anyway — this only prevents the
4595
+ # child from crashing when the chart-context body references them.
4596
+
4597
+ # noinspection PyProtectedMember
4598
+ @module_property
4599
+ def avg_losing_trade() -> PyneFloat:
4600
+ if lib._script is None:
4601
+ return 0.0
4602
+ position = lib._script.position
4603
+ if position.losstrades == 0:
4604
+ return na_float
4605
+ return position.grossloss / position.losstrades
4606
+
4607
+
4608
+ # noinspection PyProtectedMember
4609
+ @module_property
4610
+ def avg_trade() -> PyneFloat:
4611
+ if lib._script is None:
4612
+ return 0.0
4613
+ position = lib._script.position
4614
+ if position.closed_trades_count == 0:
4615
+ return na_float
4616
+ return position.netprofit / position.closed_trades_count
4617
+
4618
+
4619
+ # noinspection PyProtectedMember
4620
+ @module_property
4621
+ def avg_winning_trade() -> PyneFloat:
4622
+ if lib._script is None:
4623
+ return 0.0
4624
+ position = lib._script.position
4625
+ if position.wintrades == 0:
4626
+ return na_float
4627
+ return position.grossprofit / position.wintrades
4628
+
4629
+
4630
+ # noinspection PyProtectedMember
4631
+ @module_property
4632
+ def equity() -> PyneFloat:
4633
+ if lib._script is None:
4634
+ return 0.0
4635
+ return lib._script.position.equity
4636
+
4637
+
4638
+ # noinspection PyProtectedMember
4639
+ @module_property
4640
+ def eventrades() -> PyneInt:
4641
+ if lib._script is None:
4642
+ return 0
4643
+ return lib._script.position.eventrades
4644
+
4645
+
4646
+ # noinspection PyProtectedMember
4647
+ @module_property
4648
+ def initial_capital() -> float:
4649
+ if lib._script is None:
4650
+ return 0.0
4651
+ return lib._script.initial_capital
4652
+
4653
+
4654
+ # noinspection PyProtectedMember
4655
+ @module_property
4656
+ def grossloss() -> PyneFloat:
4657
+ if lib._script is None:
4658
+ return 0.0
4659
+ return lib._script.position.grossloss + lib._script.position.open_commission
4660
+
4661
+
4662
+ # noinspection PyProtectedMember
4663
+ @module_property
4664
+ def grossprofit() -> PyneFloat:
4665
+ if lib._script is None:
4666
+ return 0.0
4667
+ return lib._script.position.grossprofit
4668
+
4669
+
4670
+ # noinspection PyProtectedMember
4671
+ @module_property
4672
+ def losstrades() -> int:
4673
+ if lib._script is None:
4674
+ return 0
4675
+ return lib._script.position.losstrades
4676
+
4677
+
4678
+ # noinspection PyProtectedMember
4679
+ @module_property
4680
+ def margin_liquidation_price() -> PyneFloat:
4681
+ """
4682
+ The price at which the open position would be liquidated by a margin call.
4683
+
4684
+ NOT IMPLEMENTED: PyneCore does not model margin calls (see the margin_long /
4685
+ margin_short strategy() arguments, which it accepts but does not enforce), so
4686
+ there is no liquidation level to report. Returns na, which is also what
4687
+ TradingView returns when no position is open or margin is not in use.
4688
+ """
4689
+ return na_float
4690
+
4691
+
4692
+ # noinspection PyProtectedMember
4693
+ @module_property
4694
+ def max_drawdown() -> PyneFloat:
4695
+ if lib._script is None:
4696
+ return 0.0
4697
+ return lib._script.position.max_drawdown
4698
+
4699
+
4700
+ # noinspection PyProtectedMember
4701
+ @module_property
4702
+ def max_drawdown_percent() -> PyneFloat:
4703
+ if lib._script is None:
4704
+ return 0.0
4705
+ initial = lib._script.initial_capital
4706
+ if initial == 0.0:
4707
+ return 0.0
4708
+ return lib._script.position.max_drawdown / initial * 100.0
4709
+
4710
+
4711
+ # noinspection PyProtectedMember
4712
+ @module_property
4713
+ def max_runup() -> PyneFloat:
4714
+ if lib._script is None:
4715
+ return 0.0
4716
+ return lib._script.position.max_runup
4717
+
4718
+
4719
+ # noinspection PyProtectedMember
4720
+ @module_property
4721
+ def netprofit() -> PyneFloat:
4722
+ if lib._script is None:
4723
+ return 0.0
4724
+ return lib._script.position.netprofit
4725
+
4726
+
4727
+ # noinspection PyProtectedMember
4728
+ @module_property
4729
+ def netprofit_percent() -> PyneFloat:
4730
+ if lib._script is None:
4731
+ return 0.0
4732
+ initial = lib._script.initial_capital
4733
+ if initial == 0.0:
4734
+ return 0.0
4735
+ return lib._script.position.netprofit / initial * 100.0
4736
+
4737
+
4738
+ # noinspection PyProtectedMember
4739
+ @module_property
4740
+ def openprofit() -> PyneFloat:
4741
+ if lib._script is None:
4742
+ return 0.0
4743
+ return lib._script.position.openprofit
4744
+
4745
+
4746
+ # noinspection PyProtectedMember
4747
+ @module_property
4748
+ def openprofit_percent() -> PyneFloat:
4749
+ if lib._script is None:
4750
+ return 0.0
4751
+ initial = lib._script.initial_capital
4752
+ if initial == 0.0:
4753
+ return 0.0
4754
+ return lib._script.position.openprofit / initial * 100.0
4755
+
4756
+
4757
+ # noinspection PyProtectedMember
4758
+ @module_property
4759
+ def position_size() -> PyneFloat:
4760
+ if lib._script is None:
4761
+ return 0.0
4762
+ return lib._script.position.size
4763
+
4764
+
4765
+ # noinspection PyProtectedMember
4766
+ @module_property
4767
+ def position_avg_price() -> PyneFloat:
4768
+ if lib._script is None:
4769
+ return 0.0
4770
+ return lib._script.position.avg_price
4771
+
4772
+
4773
+ # noinspection PyProtectedMember
4774
+ @module_property
4775
+ def wintrades() -> PyneInt:
4776
+ if lib._script is None:
4777
+ return 0
4778
+ return lib._script.position.wintrades