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