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,787 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Position tracking for live broker trading.
|
|
3
|
+
|
|
4
|
+
:class:`BrokerPosition` extends :class:`~pynecore.lib.strategy.PositionBase`
|
|
5
|
+
with no simulation logic — the exchange is the source of truth for fills,
|
|
6
|
+
prices, fees, and margin state.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import deque
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from pynecore import lib
|
|
14
|
+
from pynecore.core.broker.intent_builder import CLOSE_ALL_EXIT_ID, CLOSE_EXIT_ID_PREFIX
|
|
15
|
+
from pynecore.core.broker.models import LegType
|
|
16
|
+
from pynecore.lib.log import broker_warning as _blog_warning
|
|
17
|
+
from pynecore.lib.strategy import PositionBase, Trade
|
|
18
|
+
from pynecore.types.na import na_float
|
|
19
|
+
from pynecore.types import NA
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from pynecore.lib.strategy import direction
|
|
23
|
+
from pynecore.lib.strategy import Order
|
|
24
|
+
from pynecore.types.strategy import QtyType
|
|
25
|
+
from pynecore.core.broker.models import OrderEvent
|
|
26
|
+
|
|
27
|
+
__all__ = ['BrokerPosition']
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BrokerPosition(PositionBase):
|
|
31
|
+
"""
|
|
32
|
+
Position state tracker for live broker trading.
|
|
33
|
+
|
|
34
|
+
The exchange determines fills, prices, fees, and margin state;
|
|
35
|
+
:meth:`record_fill` consumes :class:`OrderEvent` objects emitted by a
|
|
36
|
+
:class:`~pynecore.core.plugin.broker.BrokerPlugin` and updates the
|
|
37
|
+
local view of the position accordingly.
|
|
38
|
+
|
|
39
|
+
Trades are tracked FIFO: the first entry filled is the first closed
|
|
40
|
+
when the position is reduced, matching TradingView default semantics.
|
|
41
|
+
|
|
42
|
+
Note: margin, liquidation price, and fee currency conversion are all
|
|
43
|
+
handled by the exchange. This class only records what the exchange
|
|
44
|
+
tells it.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
__slots__ = (
|
|
48
|
+
'size', 'sign', 'avg_price',
|
|
49
|
+
'netprofit', 'openprofit', 'grossprofit', 'grossloss',
|
|
50
|
+
'open_commission',
|
|
51
|
+
'eventrades', 'wintrades', 'losstrades',
|
|
52
|
+
'closed_trades_count',
|
|
53
|
+
'max_drawdown', 'max_runup', 'max_equity',
|
|
54
|
+
'open_trades', 'closed_trades', 'new_closed_trades',
|
|
55
|
+
'entry_orders', 'exit_orders',
|
|
56
|
+
# Per-evaluation set of close keys already seen this script run, so a
|
|
57
|
+
# second same-key ``strategy.close()`` THIS evaluation nets onto the
|
|
58
|
+
# first while a next-tick re-issue (calc_on_every_tick) replaces it.
|
|
59
|
+
'_closes_this_eval',
|
|
60
|
+
# === Risk management state (mirrors SimPosition) ===
|
|
61
|
+
# Configuration set by ``strategy.risk.*`` setters:
|
|
62
|
+
'risk_allowed_direction',
|
|
63
|
+
'risk_max_drawdown_value', 'risk_max_drawdown_type', 'risk_max_drawdown_alert',
|
|
64
|
+
'risk_max_intraday_loss_value', 'risk_max_intraday_loss_type',
|
|
65
|
+
'risk_max_intraday_loss_alert',
|
|
66
|
+
'risk_max_cons_loss_days', 'risk_max_cons_loss_days_alert',
|
|
67
|
+
'risk_max_intraday_filled_orders', 'risk_max_intraday_filled_orders_alert',
|
|
68
|
+
'risk_max_position_size',
|
|
69
|
+
# Runtime counters / day-rollover tracking:
|
|
70
|
+
'risk_cons_loss_days', 'risk_last_trading_day', 'risk_last_day_equity',
|
|
71
|
+
'risk_intraday_filled_orders', 'risk_intraday_start_equity',
|
|
72
|
+
'risk_halt_trading',
|
|
73
|
+
'_current_price',
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def __init__(self) -> None:
|
|
77
|
+
self.size: float = 0.0
|
|
78
|
+
self.sign: float = 0.0
|
|
79
|
+
self.avg_price = na_float
|
|
80
|
+
|
|
81
|
+
self.netprofit: float = 0.0
|
|
82
|
+
self.openprofit: float = 0.0
|
|
83
|
+
self.grossprofit: float = 0.0
|
|
84
|
+
self.grossloss: float = 0.0
|
|
85
|
+
self.open_commission: float = 0.0
|
|
86
|
+
|
|
87
|
+
self.eventrades: int = 0
|
|
88
|
+
self.wintrades: int = 0
|
|
89
|
+
self.losstrades: int = 0
|
|
90
|
+
self.closed_trades_count: int = 0
|
|
91
|
+
self.max_drawdown: float = 0.0
|
|
92
|
+
self.max_runup: float = 0.0
|
|
93
|
+
# Mark-to-market peak equity, used by ``_peak_equity`` for the
|
|
94
|
+
# ``max_drawdown(percent_of_equity)`` threshold. Updated on every
|
|
95
|
+
# :meth:`update_unrealized_pnl` and :meth:`record_fill` call.
|
|
96
|
+
self.max_equity: float = -float("inf")
|
|
97
|
+
|
|
98
|
+
self.open_trades: list[Trade] = []
|
|
99
|
+
self.closed_trades: deque[Trade] = deque(maxlen=9000)
|
|
100
|
+
self.new_closed_trades: list[Trade] = []
|
|
101
|
+
|
|
102
|
+
self.entry_orders: dict[str | None, 'Order'] = {}
|
|
103
|
+
# Composite key ``(exit_id, from_entry)`` mirrors
|
|
104
|
+
# :class:`~pynecore.lib.strategy.SimPosition.exit_orders`. Single-field
|
|
105
|
+
# keys collide on partial-TP fan-out (multiple exits for one entry)
|
|
106
|
+
# and on ``from_entry=na`` fan-out (one exit_id, many per-entry rows).
|
|
107
|
+
self.exit_orders: dict[tuple[str | None, str | None], 'Order'] = {}
|
|
108
|
+
|
|
109
|
+
# Close keys (``(exit_id, order_id)``) already issued in the current
|
|
110
|
+
# script evaluation; reset by :meth:`begin_evaluation`. See
|
|
111
|
+
# :meth:`_add_order` for the same-eval netting it drives.
|
|
112
|
+
self._closes_this_eval: set[tuple[str | None, str | None]] = set()
|
|
113
|
+
|
|
114
|
+
# === Risk management state ===
|
|
115
|
+
# Configuration (filled by the ``strategy.risk.*`` setters via
|
|
116
|
+
# ``__init__.py``'s shared lib-property bridge):
|
|
117
|
+
self.risk_allowed_direction: 'direction.Direction | None' = None
|
|
118
|
+
self.risk_max_drawdown_value: float | None = None
|
|
119
|
+
self.risk_max_drawdown_type: 'QtyType | None' = None
|
|
120
|
+
self.risk_max_drawdown_alert: str | None = None
|
|
121
|
+
self.risk_max_intraday_loss_value: float | None = None
|
|
122
|
+
self.risk_max_intraday_loss_type: 'QtyType | None' = None
|
|
123
|
+
self.risk_max_intraday_loss_alert: str | None = None
|
|
124
|
+
self.risk_max_cons_loss_days: int | None = None
|
|
125
|
+
self.risk_max_cons_loss_days_alert: str | None = None
|
|
126
|
+
self.risk_max_intraday_filled_orders: int | None = None
|
|
127
|
+
self.risk_max_intraday_filled_orders_alert: str | None = None
|
|
128
|
+
self.risk_max_position_size: float | None = None
|
|
129
|
+
# Runtime counters / day-rollover tracking:
|
|
130
|
+
self.risk_cons_loss_days: int = 0
|
|
131
|
+
self.risk_last_trading_day: int = -1
|
|
132
|
+
self.risk_last_day_equity: float = 0.0
|
|
133
|
+
self.risk_intraday_filled_orders: int = 0
|
|
134
|
+
self.risk_intraday_start_equity: float = 0.0
|
|
135
|
+
self.risk_halt_trading: bool = False
|
|
136
|
+
|
|
137
|
+
self._current_price: float = 0.0
|
|
138
|
+
# Inherited from PositionBase; unused on the live path (close stacking is
|
|
139
|
+
# backtest-only), initialized so _next_close_seq() never raises if called.
|
|
140
|
+
self._close_seq_counter: int = 0
|
|
141
|
+
|
|
142
|
+
# === Pine API compatibility shims ======================================
|
|
143
|
+
# Pine strategy.* functions read ``position.c`` / ``.o`` / ``.h`` / ``.l``
|
|
144
|
+
# for the simulator's creation-time margin check. In broker mode those
|
|
145
|
+
# attributes are served from the live OHLCV module; the exchange enforces
|
|
146
|
+
# margin for real, so the Pine-level check still acts as a safety net
|
|
147
|
+
# on script-side state without a separate simulator update path.
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def c(self) -> float:
|
|
151
|
+
try:
|
|
152
|
+
v = lib.close
|
|
153
|
+
except AttributeError:
|
|
154
|
+
return self._current_price or 0.0
|
|
155
|
+
try:
|
|
156
|
+
return float(v) if v is not None else self._current_price or 0.0
|
|
157
|
+
except (TypeError, ValueError):
|
|
158
|
+
return self._current_price or 0.0
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def o(self) -> float:
|
|
162
|
+
try:
|
|
163
|
+
return float(lib.open)
|
|
164
|
+
except (AttributeError, TypeError, ValueError):
|
|
165
|
+
return self.c
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def h(self) -> float:
|
|
169
|
+
try:
|
|
170
|
+
return float(lib.high)
|
|
171
|
+
except (AttributeError, TypeError, ValueError):
|
|
172
|
+
return self.c
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def l(self) -> float: # noqa: E743 — mirrors the Pine attribute name
|
|
176
|
+
try:
|
|
177
|
+
return float(lib.low)
|
|
178
|
+
except (AttributeError, TypeError, ValueError):
|
|
179
|
+
return self.c
|
|
180
|
+
|
|
181
|
+
# === Pine-side order book ===
|
|
182
|
+
|
|
183
|
+
def begin_evaluation(self) -> None:
|
|
184
|
+
"""Mark the start of a fresh script evaluation (one ``main()`` run).
|
|
185
|
+
|
|
186
|
+
Called by the runner before the libraries / ``main`` execute, in broker
|
|
187
|
+
mode only. It clears the per-evaluation close-key set so that two
|
|
188
|
+
``strategy.close()`` calls issued in the SAME evaluation net onto one
|
|
189
|
+
order, while the SAME close re-issued on the next ``calc_on_every_tick``
|
|
190
|
+
evaluation replaces the pending order instead of doubling it. There is
|
|
191
|
+
no other per-evaluation order-book reset in live mode (the Pine order
|
|
192
|
+
book is purely event-driven), so this is the netting's idempotency
|
|
193
|
+
anchor.
|
|
194
|
+
"""
|
|
195
|
+
self._closes_this_eval.clear()
|
|
196
|
+
|
|
197
|
+
def _add_order(self, order: 'Order') -> None:
|
|
198
|
+
"""Register an order locally (the sync engine forwards it to the exchange).
|
|
199
|
+
|
|
200
|
+
Pre-submit risk gates run before the order is enqueued — same policy
|
|
201
|
+
as :meth:`SimPosition.fill_order` enforces at fill time, but applied
|
|
202
|
+
at the submit boundary because the broker fill is asynchronous.
|
|
203
|
+
Rejected entry/normal orders are silently dropped (matching the sim
|
|
204
|
+
``_remove_order`` behavior on cap/direction violation); the
|
|
205
|
+
:attr:`risk_halt_trading` flag is set out-of-band by
|
|
206
|
+
:meth:`_enforce_post_bar_risk`.
|
|
207
|
+
"""
|
|
208
|
+
order.bar_index = int(lib.bar_index)
|
|
209
|
+
# noinspection PyProtectedMember
|
|
210
|
+
from pynecore.lib.strategy import (
|
|
211
|
+
_order_type_close, _order_type_entry, _order_type_normal,
|
|
212
|
+
)
|
|
213
|
+
if order.order_type in (_order_type_entry, _order_type_normal):
|
|
214
|
+
if self._is_intraday_filled_cap_reached():
|
|
215
|
+
return
|
|
216
|
+
adjusted = self._adjust_for_max_position_size(float(order.size), order.sign)
|
|
217
|
+
if adjusted is None:
|
|
218
|
+
return
|
|
219
|
+
order.size = adjusted
|
|
220
|
+
if self.size == 0.0 and not self._is_direction_allowed(order.sign):
|
|
221
|
+
return
|
|
222
|
+
if order.order_type == _order_type_close:
|
|
223
|
+
key = (order.exit_id, order.order_id)
|
|
224
|
+
existing = self.exit_orders.get(key)
|
|
225
|
+
# Netting is for market closes only (``strategy.close(id)`` /
|
|
226
|
+
# ``strategy.close_all()``), identified by their reserved exit-id
|
|
227
|
+
# patterns. A sticky ``strategy.exit`` bracket re-issued in the SAME
|
|
228
|
+
# evaluation must still REPLACE: summing its size would dispatch an
|
|
229
|
+
# oversized protective order and the first leg's stale limit/stop/
|
|
230
|
+
# trailing levels would survive (netting only carries metadata).
|
|
231
|
+
exit_id = order.exit_id
|
|
232
|
+
is_market_close = exit_id == CLOSE_ALL_EXIT_ID or (
|
|
233
|
+
exit_id is not None and exit_id.startswith(CLOSE_EXIT_ID_PREFIX)
|
|
234
|
+
)
|
|
235
|
+
if is_market_close and key in self._closes_this_eval and existing is not None:
|
|
236
|
+
# Second+ same-key close THIS evaluation: net the slices into
|
|
237
|
+
# one reduce-only market close. Both ``strategy.close`` qty
|
|
238
|
+
# expressions are evaluated against the same ``position.size``
|
|
239
|
+
# (no fill lands mid-evaluation), so the slices simply sum; the
|
|
240
|
+
# over-close cap is applied later by the sync engine. Metadata
|
|
241
|
+
# is last-wins, matching the prior overwrite behaviour for this
|
|
242
|
+
# collision class. A NEXT-evaluation re-issue takes the ``else``
|
|
243
|
+
# branch (``begin_evaluation`` cleared the key) and replaces the
|
|
244
|
+
# pending order, keeping calc_on_every_tick idempotent.
|
|
245
|
+
existing.size += order.size
|
|
246
|
+
existing.reserved_size = abs(existing.size)
|
|
247
|
+
existing.comment = order.comment
|
|
248
|
+
existing.alert_message = order.alert_message
|
|
249
|
+
else:
|
|
250
|
+
self._closes_this_eval.add(key)
|
|
251
|
+
self.exit_orders[key] = order
|
|
252
|
+
else:
|
|
253
|
+
self.entry_orders[order.order_id] = order
|
|
254
|
+
|
|
255
|
+
def _remove_order(self, order: 'Order') -> None:
|
|
256
|
+
"""Cancel an order locally."""
|
|
257
|
+
order.cancelled = True
|
|
258
|
+
# noinspection PyProtectedMember
|
|
259
|
+
from pynecore.lib.strategy import _order_type_close
|
|
260
|
+
if order.order_type == _order_type_close:
|
|
261
|
+
self.exit_orders.pop((order.exit_id, order.order_id), None)
|
|
262
|
+
else:
|
|
263
|
+
self.entry_orders.pop(order.order_id, None)
|
|
264
|
+
|
|
265
|
+
def _remove_order_by_id(self, order_id: str) -> None:
|
|
266
|
+
# TV-verified semantics: ``strategy.cancel(id)`` matches an exit by
|
|
267
|
+
# its ``exit_id`` and an entry by its entry id; no cross-matching.
|
|
268
|
+
for exit_order in list(self.exit_orders.values()):
|
|
269
|
+
if exit_order.exit_id == order_id:
|
|
270
|
+
self._remove_order(exit_order)
|
|
271
|
+
entry = self.entry_orders.get(order_id)
|
|
272
|
+
if entry is not None:
|
|
273
|
+
self._remove_order(entry)
|
|
274
|
+
|
|
275
|
+
def _cancel_all_orders(self) -> None:
|
|
276
|
+
# No ``orderbook`` attribute — that lives on ``SimPosition`` and drives
|
|
277
|
+
# the simulator's price-keyed fill loop, which has no analog in live
|
|
278
|
+
# trading. Clearing the two Pine-side dicts is enough; the next
|
|
279
|
+
# ``OrderSyncEngine.sync()`` diffs against ``_active_intents`` and
|
|
280
|
+
# dispatches a per-id cancel for every previously tracked intent.
|
|
281
|
+
self.entry_orders.clear()
|
|
282
|
+
self.exit_orders.clear()
|
|
283
|
+
|
|
284
|
+
# === Restart-time Pine-side reconstruction ===
|
|
285
|
+
|
|
286
|
+
def reconstruct_exit_order(
|
|
287
|
+
self,
|
|
288
|
+
*,
|
|
289
|
+
pine_id: str,
|
|
290
|
+
from_entry: str,
|
|
291
|
+
side: str,
|
|
292
|
+
qty: float,
|
|
293
|
+
tp_price: float | None,
|
|
294
|
+
sl_price: float | None,
|
|
295
|
+
trail_price: float | None,
|
|
296
|
+
trail_offset: float | None,
|
|
297
|
+
oca_name: str | None = None,
|
|
298
|
+
oca_type: str | None = None,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Re-install a persistent ``strategy.exit`` bracket order after a restart.
|
|
301
|
+
|
|
302
|
+
Pine ``strategy.exit`` orders are persistent: once placed they live in
|
|
303
|
+
:attr:`exit_orders` across bars (the script need not re-emit them) until
|
|
304
|
+
they fill or are cancelled. A fresh process starts with empty order
|
|
305
|
+
dicts, so the bracket the previous run armed is invisible to
|
|
306
|
+
:func:`~pynecore.core.broker.intent_builder.build_intents` until it is
|
|
307
|
+
rebuilt here from the durable broker-side ledger (the one-way
|
|
308
|
+
bracket-ownership rows or the engine-trigger partial-leg ledger). The
|
|
309
|
+
:class:`~pynecore.core.broker.sync_engine.OrderSyncEngine` calls this
|
|
310
|
+
once at startup so the diff sees the same exit it saw before the crash
|
|
311
|
+
and adopts (rather than tears down) the live broker protection.
|
|
312
|
+
|
|
313
|
+
``side`` is the CLOSE side (``"buy"``/``"sell"``); the stored
|
|
314
|
+
:class:`~pynecore.lib.strategy.Order` carries the opposite-of-position
|
|
315
|
+
signed size so ``build_intents`` re-derives the same side. Tick fields
|
|
316
|
+
are left ``None`` — the ledger persists resolved absolute prices, never
|
|
317
|
+
the original tick distances.
|
|
318
|
+
|
|
319
|
+
:param pine_id: The ``strategy.exit(id=...)`` value (the exit id).
|
|
320
|
+
:param from_entry: The parent entry id the exit protects.
|
|
321
|
+
:param side: The exit (close) side, ``"buy"`` or ``"sell"``.
|
|
322
|
+
:param qty: Exit quantity magnitude.
|
|
323
|
+
:param tp_price: Absolute take-profit price, or ``None``.
|
|
324
|
+
:param sl_price: Absolute stop-loss price, or ``None``.
|
|
325
|
+
:param trail_price: Absolute trailing-stop activation price, or ``None``.
|
|
326
|
+
:param trail_offset: Trailing-stop offset (price units), or ``None``.
|
|
327
|
+
:param oca_name: OCA group name, or ``None``.
|
|
328
|
+
:param oca_type: OCA type string (``"reduce"`` / ``"cancel"`` /
|
|
329
|
+
``"none"``), or ``None`` for rows persisted before the OCA fields
|
|
330
|
+
existed. Restored so ``build_intents`` re-derives the same group the
|
|
331
|
+
exit was emitted under and the cross-bracket OCA-cancel cascade keeps
|
|
332
|
+
firing across the restart.
|
|
333
|
+
"""
|
|
334
|
+
# noinspection PyProtectedMember
|
|
335
|
+
from pynecore.lib.strategy import Order, _order_type_close, oca as _oca
|
|
336
|
+
signed_size = qty if side == "buy" else -qty
|
|
337
|
+
order = Order(
|
|
338
|
+
from_entry,
|
|
339
|
+
signed_size,
|
|
340
|
+
order_type=_order_type_close,
|
|
341
|
+
exit_id=pine_id,
|
|
342
|
+
limit=tp_price,
|
|
343
|
+
stop=sl_price,
|
|
344
|
+
trail_price=trail_price,
|
|
345
|
+
trail_offset=trail_offset,
|
|
346
|
+
oca_name=oca_name,
|
|
347
|
+
oca_type=_oca.Oca(oca_type) if oca_type is not None else None,
|
|
348
|
+
)
|
|
349
|
+
self.exit_orders[(pine_id, from_entry)] = order
|
|
350
|
+
|
|
351
|
+
def reconstruct_entry_order(
|
|
352
|
+
self,
|
|
353
|
+
*,
|
|
354
|
+
pine_id: str,
|
|
355
|
+
side: str,
|
|
356
|
+
qty: float,
|
|
357
|
+
limit: float | None,
|
|
358
|
+
stop: float | None,
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Re-install a persistent ``strategy.entry`` working order after a restart.
|
|
361
|
+
|
|
362
|
+
Pine ``strategy.entry`` / ``strategy.order`` working orders are
|
|
363
|
+
persistent: once placed a pending LIMIT / STOP entry lives in
|
|
364
|
+
:attr:`entry_orders` across bars (the script need not re-emit it) until
|
|
365
|
+
it fills or is cancelled. A fresh process starts with empty order dicts,
|
|
366
|
+
so a first-bar ``strategy.cancel`` after a restart would operate on an
|
|
367
|
+
empty book (a silent no-op) and the live broker working order would be
|
|
368
|
+
stranded — neither adopted nor cancelled. The
|
|
369
|
+
:class:`~pynecore.core.broker.sync_engine.OrderSyncEngine` rebuilds the
|
|
370
|
+
entry here, once, from the live broker snapshot so the first post-restart
|
|
371
|
+
script sees the same order it placed: a ``strategy.cancel`` removes it and
|
|
372
|
+
the diff retires the live order, while leaving it standing (or re-emitting
|
|
373
|
+
it) adopts the live order without a duplicate dispatch.
|
|
374
|
+
|
|
375
|
+
``side`` is the entry side (``"buy"``/``"sell"``); the stored
|
|
376
|
+
:class:`~pynecore.lib.strategy.Order` carries the matching signed size so
|
|
377
|
+
:func:`~pynecore.core.broker.intent_builder.build_intents` re-derives the
|
|
378
|
+
same intent. Tick fields are left ``None`` — the venue reports resolved
|
|
379
|
+
absolute prices, never the original tick distances.
|
|
380
|
+
|
|
381
|
+
:param pine_id: The ``strategy.entry(id=...)`` value (the entry id).
|
|
382
|
+
:param side: The entry side, ``"buy"`` or ``"sell"``.
|
|
383
|
+
:param qty: Entry quantity magnitude.
|
|
384
|
+
:param limit: Absolute limit price, or ``None`` (STOP entry).
|
|
385
|
+
:param stop: Absolute stop trigger price, or ``None`` (LIMIT entry).
|
|
386
|
+
"""
|
|
387
|
+
# noinspection PyProtectedMember
|
|
388
|
+
from pynecore.lib.strategy import Order, _order_type_entry
|
|
389
|
+
signed_size = qty if side == "buy" else -qty
|
|
390
|
+
order = Order(
|
|
391
|
+
pine_id,
|
|
392
|
+
signed_size,
|
|
393
|
+
order_type=_order_type_entry,
|
|
394
|
+
limit=limit,
|
|
395
|
+
stop=stop,
|
|
396
|
+
)
|
|
397
|
+
self.entry_orders[pine_id] = order
|
|
398
|
+
|
|
399
|
+
def reconstruct_parent_trade(
|
|
400
|
+
self,
|
|
401
|
+
*,
|
|
402
|
+
entry_id: str,
|
|
403
|
+
size: float,
|
|
404
|
+
entry_price: float,
|
|
405
|
+
) -> None:
|
|
406
|
+
"""Seed one open :class:`~pynecore.lib.strategy.Trade` for an adopted parent.
|
|
407
|
+
|
|
408
|
+
Startup adoption restores :attr:`size`/:attr:`avg_price` but leaves
|
|
409
|
+
:attr:`open_trades` empty. A partial-quantity bracket needs the parent's
|
|
410
|
+
open qty to classify
|
|
411
|
+
:attr:`~pynecore.core.broker.models.ExitIntent.is_partial_qty_bracket`
|
|
412
|
+
consistently across bars (``build_intents`` derives the parent total
|
|
413
|
+
from :attr:`open_trades`); without it the exit would be misclassified as
|
|
414
|
+
a whole-row bracket and adopted on the wrong dispatch path. The trade is
|
|
415
|
+
inert until a real broker fill routes through :meth:`record_fill` (the
|
|
416
|
+
sole :attr:`open_trades` mutator), so seeding it cannot emit an intent or
|
|
417
|
+
re-open anything.
|
|
418
|
+
|
|
419
|
+
:param entry_id: The parent entry id (``from_entry``).
|
|
420
|
+
:param size: Signed parent open size (positive long, negative short).
|
|
421
|
+
:param entry_price: Parent average entry price.
|
|
422
|
+
"""
|
|
423
|
+
if any(t.entry_id == entry_id for t in self.open_trades):
|
|
424
|
+
return
|
|
425
|
+
# ``entry_equity`` stays 0.0: the parent opened in a prior process so
|
|
426
|
+
# its true entry equity is unknowable, and startup adoption runs in
|
|
427
|
+
# ``start_broker`` before the script is attached (``self.equity`` reads
|
|
428
|
+
# ``lib._script``, not yet set). The broker close path never divides by
|
|
429
|
+
# a trade's entry equity, so the placeholder is inert.
|
|
430
|
+
self.open_trades.append(Trade(
|
|
431
|
+
size=size,
|
|
432
|
+
entry_id=entry_id,
|
|
433
|
+
entry_bar_index=int(getattr(lib, 'bar_index', 0)),
|
|
434
|
+
entry_time=0,
|
|
435
|
+
entry_price=entry_price,
|
|
436
|
+
commission=0.0,
|
|
437
|
+
entry_equity=0.0,
|
|
438
|
+
))
|
|
439
|
+
|
|
440
|
+
# === Exchange-side state updates ===
|
|
441
|
+
|
|
442
|
+
def record_fill(self, event: 'OrderEvent') -> bool:
|
|
443
|
+
"""
|
|
444
|
+
Record an exchange fill.
|
|
445
|
+
|
|
446
|
+
:param event: An :class:`OrderEvent` with ``fill_qty`` and
|
|
447
|
+
``fill_price`` populated, plus Pine identity fields
|
|
448
|
+
(``pine_id``, ``from_entry``, ``leg_type``) filled by the plugin.
|
|
449
|
+
:return: ``True`` if the position side changed as a result of this fill.
|
|
450
|
+
"""
|
|
451
|
+
fill_qty = event.fill_qty or 0.0
|
|
452
|
+
fill_price = event.fill_price or 0.0
|
|
453
|
+
if fill_qty <= 0.0 or fill_price <= 0.0:
|
|
454
|
+
# A zero-or-missing qty/price means the broker plugin emitted a
|
|
455
|
+
# fill event without resolving the actual fill quantity / price.
|
|
456
|
+
# Without a warning the silent skip leaves ``position.size``
|
|
457
|
+
# stuck at zero — the script keeps thinking it is flat and
|
|
458
|
+
# re-fires the entry next bar. Surface the offending event so
|
|
459
|
+
# the operator can spot the broker-side data hole instead of
|
|
460
|
+
# debugging by guesswork.
|
|
461
|
+
_blog_warning(
|
|
462
|
+
"ignoring fill with zero qty/price (qty=%s price=%s pine=%r leg=%s) "
|
|
463
|
+
"— position.size NOT updated",
|
|
464
|
+
fill_qty, fill_price,
|
|
465
|
+
event.pine_id, event.leg_type,
|
|
466
|
+
)
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
signed_delta = fill_qty if event.order.side == "buy" else -fill_qty
|
|
470
|
+
old_sign = self.sign
|
|
471
|
+
new_size = self.size + signed_delta
|
|
472
|
+
|
|
473
|
+
# Commission bookkeeping — realized fee becomes part of net P&L at close
|
|
474
|
+
fee = event.fee
|
|
475
|
+
|
|
476
|
+
# Risk management: count every filled order toward the intraday cap,
|
|
477
|
+
# matching ``SimPosition._fill_order``. The counter is read by the
|
|
478
|
+
# pre-submit gate in :meth:`_add_order` and the post-bar halt in
|
|
479
|
+
# :meth:`_enforce_post_bar_risk`.
|
|
480
|
+
self.risk_intraday_filled_orders += 1
|
|
481
|
+
|
|
482
|
+
if self.size == 0.0 or (old_sign * signed_delta) > 0.0:
|
|
483
|
+
# Opening or adding to an existing position (same direction)
|
|
484
|
+
new_abs = abs(new_size)
|
|
485
|
+
old_abs = abs(self.size)
|
|
486
|
+
if old_abs == 0.0 or isinstance(self.avg_price, NA) or self.avg_price != self.avg_price:
|
|
487
|
+
self.avg_price = fill_price
|
|
488
|
+
else:
|
|
489
|
+
self.avg_price = (self.avg_price * old_abs + fill_price * fill_qty) / new_abs
|
|
490
|
+
self.size = new_size
|
|
491
|
+
self.sign = 1.0 if new_size > 0.0 else (-1.0 if new_size < 0.0 else 0.0)
|
|
492
|
+
|
|
493
|
+
trade = Trade(
|
|
494
|
+
size=signed_delta,
|
|
495
|
+
entry_id=event.pine_id,
|
|
496
|
+
entry_bar_index=int(getattr(lib, 'bar_index', 0)),
|
|
497
|
+
entry_time=int(event.timestamp * 1000.0),
|
|
498
|
+
entry_price=fill_price,
|
|
499
|
+
commission=fee,
|
|
500
|
+
entry_comment=None,
|
|
501
|
+
entry_equity=self.equity,
|
|
502
|
+
)
|
|
503
|
+
self.open_trades.append(trade)
|
|
504
|
+
self.open_commission += fee
|
|
505
|
+
# The entry Order stays in ``entry_orders`` for intent stability, but
|
|
506
|
+
# its filled slice now lives in ``open_trades``. Record how much of it
|
|
507
|
+
# has filled so ``strategy.exit``'s bound-size reservation does not
|
|
508
|
+
# count the same quantity twice (issue BYBIT-001).
|
|
509
|
+
entry_order = self.entry_orders.get(event.pine_id)
|
|
510
|
+
if entry_order is not None:
|
|
511
|
+
entry_order.filled_qty += fill_qty
|
|
512
|
+
return False
|
|
513
|
+
|
|
514
|
+
# Reducing or flipping — FIFO close of existing trades
|
|
515
|
+
remaining = fill_qty
|
|
516
|
+
closed_profit = 0.0
|
|
517
|
+
closed_fee = 0.0
|
|
518
|
+
while remaining > 0.0 and self.open_trades:
|
|
519
|
+
trade = self.open_trades[0]
|
|
520
|
+
trade_abs = abs(trade.size)
|
|
521
|
+
if trade_abs <= remaining + 1e-12:
|
|
522
|
+
# Close this trade fully
|
|
523
|
+
self._close_trade(trade, fill_price, event, fee_share=fee * (trade_abs / fill_qty))
|
|
524
|
+
closed_profit += trade.profit
|
|
525
|
+
closed_fee += trade.commission
|
|
526
|
+
remaining -= trade_abs
|
|
527
|
+
else:
|
|
528
|
+
# Partial close: split the trade
|
|
529
|
+
closed_piece = Trade(
|
|
530
|
+
size=trade.sign * remaining,
|
|
531
|
+
entry_id=trade.entry_id,
|
|
532
|
+
entry_bar_index=trade.entry_bar_index,
|
|
533
|
+
entry_time=trade.entry_time,
|
|
534
|
+
entry_price=trade.entry_price,
|
|
535
|
+
commission=trade.commission * (remaining / trade_abs),
|
|
536
|
+
entry_comment=trade.entry_comment,
|
|
537
|
+
entry_equity=trade.entry_equity,
|
|
538
|
+
)
|
|
539
|
+
self._close_trade(
|
|
540
|
+
closed_piece, fill_price, event,
|
|
541
|
+
fee_share=fee * (remaining / fill_qty),
|
|
542
|
+
)
|
|
543
|
+
closed_profit += closed_piece.profit
|
|
544
|
+
# Shrink the remaining open trade
|
|
545
|
+
trade.size -= closed_piece.size
|
|
546
|
+
trade.commission -= closed_piece.commission
|
|
547
|
+
remaining = 0.0
|
|
548
|
+
|
|
549
|
+
self.size += signed_delta
|
|
550
|
+
# Clamp tiny residuals to zero
|
|
551
|
+
if abs(self.size) < 1e-12:
|
|
552
|
+
self.size = 0.0
|
|
553
|
+
self.sign = 0.0
|
|
554
|
+
self.avg_price = na_float
|
|
555
|
+
else:
|
|
556
|
+
self.sign = 1.0 if self.size > 0.0 else -1.0
|
|
557
|
+
|
|
558
|
+
# If there is leftover qty after closing all open_trades → side flip.
|
|
559
|
+
# On a one-way account only an ENTRY leg may legitimately open the
|
|
560
|
+
# opposite side (stop-and-reverse). A reduce-only/exit leg (close, TP,
|
|
561
|
+
# SL, trailing) must NEVER flip: leftover qty means the local FIFO
|
|
562
|
+
# under-counted exposure (e.g. a restart adopted the net size without
|
|
563
|
+
# seeding open_trades). The authoritative net is already in ``self.size``
|
|
564
|
+
# (``self.size += signed_delta`` above), so two cases split apart:
|
|
565
|
+
# * the net kept its original sign → the exit only PARTIALLY reduced;
|
|
566
|
+
# the leftover is a stale FIFO row shortfall, NOT an over-close.
|
|
567
|
+
# Keep the (already-correct) residual size — clamping to flat would
|
|
568
|
+
# drop live broker exposure and let the script re-fire the entry.
|
|
569
|
+
# * the net reached or crossed zero → the exit closed at least the
|
|
570
|
+
# whole position; reconcile toward flat instead of fabricating an
|
|
571
|
+
# opposite position the diff engine could then "close" with a real
|
|
572
|
+
# reversing order.
|
|
573
|
+
if remaining > 0.0:
|
|
574
|
+
if event.leg_type in (
|
|
575
|
+
LegType.CLOSE, LegType.TAKE_PROFIT,
|
|
576
|
+
LegType.STOP_LOSS, LegType.TRAILING_STOP,
|
|
577
|
+
):
|
|
578
|
+
if old_sign * self.size > 0.0:
|
|
579
|
+
# Partial reduce-only fill against an under-counted FIFO:
|
|
580
|
+
# the residual already in ``self.size`` is authoritative.
|
|
581
|
+
_blog_warning(
|
|
582
|
+
"exit fill (leg=%s qty=%s) under-counted FIFO exposure "
|
|
583
|
+
"by %s — keeping residual size %s (partial reduce, "
|
|
584
|
+
"not an over-close)",
|
|
585
|
+
event.leg_type, fill_qty, remaining, self.size,
|
|
586
|
+
)
|
|
587
|
+
else:
|
|
588
|
+
_blog_warning(
|
|
589
|
+
"exit fill (leg=%s qty=%s) exceeded known FIFO exposure "
|
|
590
|
+
"by %s — clamping to flat instead of opening an opposite "
|
|
591
|
+
"position",
|
|
592
|
+
event.leg_type, fill_qty, remaining,
|
|
593
|
+
)
|
|
594
|
+
self.size = 0.0
|
|
595
|
+
self.sign = 0.0
|
|
596
|
+
self.avg_price = na_float
|
|
597
|
+
else:
|
|
598
|
+
new_size = self.sign * remaining if self.sign != 0.0 else signed_delta
|
|
599
|
+
self.size = new_size
|
|
600
|
+
self.sign = 1.0 if new_size > 0.0 else (-1.0 if new_size < 0.0 else 0.0)
|
|
601
|
+
self.avg_price = fill_price
|
|
602
|
+
flipped = Trade(
|
|
603
|
+
size=new_size,
|
|
604
|
+
entry_id=event.pine_id,
|
|
605
|
+
entry_bar_index=int(getattr(lib, 'bar_index', 0)),
|
|
606
|
+
entry_time=int(event.timestamp * 1000.0),
|
|
607
|
+
entry_price=fill_price,
|
|
608
|
+
commission=0.0,
|
|
609
|
+
entry_comment=None,
|
|
610
|
+
entry_equity=self.equity,
|
|
611
|
+
)
|
|
612
|
+
self.open_trades.append(flipped)
|
|
613
|
+
|
|
614
|
+
# Update running stats
|
|
615
|
+
self.netprofit += closed_profit
|
|
616
|
+
if closed_profit > 0.0:
|
|
617
|
+
self.grossprofit += closed_profit
|
|
618
|
+
self.wintrades += 1
|
|
619
|
+
elif closed_profit < 0.0:
|
|
620
|
+
self.grossloss += closed_profit
|
|
621
|
+
self.losstrades += 1
|
|
622
|
+
else:
|
|
623
|
+
self.eventrades += 1
|
|
624
|
+
|
|
625
|
+
self.open_commission = float(sum(t.commission for t in self.open_trades))
|
|
626
|
+
|
|
627
|
+
return self.sign != old_sign
|
|
628
|
+
|
|
629
|
+
def update_unrealized_pnl(self, current_price: float) -> None:
|
|
630
|
+
"""Mark-to-market: recompute :attr:`openprofit` at the given price.
|
|
631
|
+
|
|
632
|
+
Also rolls the :attr:`max_equity` peak forward and the
|
|
633
|
+
:attr:`max_drawdown` running maximum off the live price — these feed
|
|
634
|
+
:meth:`_peak_equity` and :meth:`_is_max_drawdown_breached` so the
|
|
635
|
+
broker risk gates see real-time equity, not just realized P&L.
|
|
636
|
+
"""
|
|
637
|
+
self._current_price = current_price
|
|
638
|
+
if not self.open_trades or current_price <= 0.0:
|
|
639
|
+
self.openprofit = 0.0
|
|
640
|
+
else:
|
|
641
|
+
total = 0.0
|
|
642
|
+
for trade in self.open_trades:
|
|
643
|
+
total += (current_price - trade.entry_price) * trade.size
|
|
644
|
+
self.openprofit = total
|
|
645
|
+
eq = float(self.equity)
|
|
646
|
+
if eq > self.max_equity:
|
|
647
|
+
self.max_equity = eq
|
|
648
|
+
# Drawdown is measured from the running peak — same metric the sim
|
|
649
|
+
# ``max_drawdown`` field tracks, just sourced from mark-to-market.
|
|
650
|
+
if self.max_equity > -float("inf"):
|
|
651
|
+
dd = self.max_equity - eq
|
|
652
|
+
if dd > self.max_drawdown:
|
|
653
|
+
self.max_drawdown = dd
|
|
654
|
+
|
|
655
|
+
def _peak_equity(self) -> float:
|
|
656
|
+
"""Reference equity for ``max_drawdown(percent_of_equity)``.
|
|
657
|
+
|
|
658
|
+
Falls back to initial capital before the first
|
|
659
|
+
:meth:`update_unrealized_pnl` (or fill) primes ``max_equity``.
|
|
660
|
+
"""
|
|
661
|
+
# noinspection PyProtectedMember
|
|
662
|
+
initial = float(lib._script.initial_capital)
|
|
663
|
+
if self.max_equity == -float("inf"):
|
|
664
|
+
return initial
|
|
665
|
+
return max(initial, float(self.max_equity))
|
|
666
|
+
|
|
667
|
+
# === Risk management hooks (called by the runner once per bar) =========
|
|
668
|
+
|
|
669
|
+
def _handle_bar_open_risk(self) -> None:
|
|
670
|
+
"""Day-rollover bookkeeping at the start of each bar.
|
|
671
|
+
|
|
672
|
+
Mirrors the rollover block in
|
|
673
|
+
:meth:`SimPosition._process_at_bar_open`: on a new trading day,
|
|
674
|
+
update ``risk_cons_loss_days`` from the prior-day equity delta,
|
|
675
|
+
reset the intraday anchors, and immediately halt if the
|
|
676
|
+
consecutive-loss-day cap is breached so queued entries cannot fill
|
|
677
|
+
at this bar's open. Distinct name from the sim hook because the
|
|
678
|
+
sim variant takes an ``ohlc`` argument and runs additional
|
|
679
|
+
simulator-specific work — the broker version only does
|
|
680
|
+
risk-related rollover.
|
|
681
|
+
"""
|
|
682
|
+
if self.risk_halt_trading:
|
|
683
|
+
return
|
|
684
|
+
try:
|
|
685
|
+
# Statically a value (module_property), at runtime still the function
|
|
686
|
+
current_trading_day = int(lib.time_tradingday())
|
|
687
|
+
except (AttributeError, TypeError, ValueError):
|
|
688
|
+
return
|
|
689
|
+
if current_trading_day == self.risk_last_trading_day:
|
|
690
|
+
return
|
|
691
|
+
current_equity = float(self.equity)
|
|
692
|
+
# On the very first bar there is no prior day to compare against —
|
|
693
|
+
# initialise the trailing-equity anchor without touching the counter.
|
|
694
|
+
if self.risk_last_trading_day != -1:
|
|
695
|
+
if current_equity < self.risk_last_day_equity:
|
|
696
|
+
self.risk_cons_loss_days += 1
|
|
697
|
+
else:
|
|
698
|
+
self.risk_cons_loss_days = 0
|
|
699
|
+
self.risk_last_day_equity = current_equity
|
|
700
|
+
self.risk_intraday_start_equity = current_equity
|
|
701
|
+
self.risk_last_trading_day = current_trading_day
|
|
702
|
+
self.risk_intraday_filled_orders = 0
|
|
703
|
+
if self._is_max_cons_loss_days_breached():
|
|
704
|
+
self._trigger_risk_halt("Max consecutive loss days reached")
|
|
705
|
+
|
|
706
|
+
def _enforce_post_bar_risk(self) -> None:
|
|
707
|
+
"""Run the post-bar ``strategy.risk.*`` checks.
|
|
708
|
+
|
|
709
|
+
Called by the runner after the script executes and before the next
|
|
710
|
+
:meth:`OrderSyncEngine.sync` so the queued risk-close goes out in
|
|
711
|
+
the same dispatch cycle. The first triggered rule wins.
|
|
712
|
+
"""
|
|
713
|
+
if self.risk_halt_trading:
|
|
714
|
+
return
|
|
715
|
+
if self._is_max_drawdown_breached():
|
|
716
|
+
self._trigger_risk_halt("Max drawdown reached")
|
|
717
|
+
return
|
|
718
|
+
if self._is_max_intraday_loss_breached():
|
|
719
|
+
self._trigger_risk_halt("Max intraday loss reached")
|
|
720
|
+
return
|
|
721
|
+
if self._is_max_cons_loss_days_breached():
|
|
722
|
+
self._trigger_risk_halt("Max consecutive loss days reached")
|
|
723
|
+
|
|
724
|
+
def _trigger_risk_halt(self, reason: str) -> None:
|
|
725
|
+
"""Cancel pending orders, queue a market close, set the halt flag.
|
|
726
|
+
|
|
727
|
+
Differs from :meth:`SimPosition._trigger_risk_halt` in two ways: no
|
|
728
|
+
synthetic fill (the exchange owns fills, not the position), and no
|
|
729
|
+
OHLC arguments (the close is a plain market order, the broker
|
|
730
|
+
decides the fill price). The next :meth:`OrderSyncEngine.sync`
|
|
731
|
+
observes the cleared books plus the queued close and dispatches
|
|
732
|
+
accordingly.
|
|
733
|
+
"""
|
|
734
|
+
# noinspection PyProtectedMember
|
|
735
|
+
from pynecore.lib.strategy import Order, _order_type_close
|
|
736
|
+
self.entry_orders.clear()
|
|
737
|
+
self.exit_orders.clear()
|
|
738
|
+
if self.size != 0.0:
|
|
739
|
+
close_order = Order(
|
|
740
|
+
None, -self.size,
|
|
741
|
+
exit_id='Risk management close',
|
|
742
|
+
order_type=_order_type_close,
|
|
743
|
+
comment=f"Close Position ({reason})",
|
|
744
|
+
)
|
|
745
|
+
self._add_order(close_order)
|
|
746
|
+
self.risk_halt_trading = True
|
|
747
|
+
|
|
748
|
+
def record_liquidation(self, event: 'OrderEvent') -> None:
|
|
749
|
+
"""Record an exchange-initiated liquidation — close all open trades."""
|
|
750
|
+
if not self.open_trades:
|
|
751
|
+
return
|
|
752
|
+
fill_price = event.fill_price or 0.0
|
|
753
|
+
for trade in list(self.open_trades):
|
|
754
|
+
self._close_trade(trade, fill_price, event, fee_share=event.fee / max(len(self.open_trades), 1))
|
|
755
|
+
self.netprofit += trade.profit
|
|
756
|
+
if trade.profit > 0.0:
|
|
757
|
+
self.grossprofit += trade.profit
|
|
758
|
+
self.wintrades += 1
|
|
759
|
+
elif trade.profit < 0.0:
|
|
760
|
+
self.grossloss += trade.profit
|
|
761
|
+
self.losstrades += 1
|
|
762
|
+
else:
|
|
763
|
+
self.eventrades += 1
|
|
764
|
+
self.size = 0.0
|
|
765
|
+
self.sign = 0.0
|
|
766
|
+
self.avg_price = na_float
|
|
767
|
+
self.openprofit = 0.0
|
|
768
|
+
self.open_commission = 0.0
|
|
769
|
+
|
|
770
|
+
# === Internals ===
|
|
771
|
+
|
|
772
|
+
def _close_trade(self, trade: Trade, fill_price: float,
|
|
773
|
+
event: 'OrderEvent', fee_share: float) -> None:
|
|
774
|
+
"""Move a (possibly split) Trade from open_trades to closed_trades."""
|
|
775
|
+
trade.exit_id = event.pine_id or ""
|
|
776
|
+
trade.exit_bar_index = int(getattr(lib, 'bar_index', 0))
|
|
777
|
+
trade.exit_time = int(event.timestamp * 1000.0)
|
|
778
|
+
trade.exit_price = fill_price
|
|
779
|
+
trade.exit_comment = ''
|
|
780
|
+
trade.commission += fee_share
|
|
781
|
+
trade.profit = (fill_price - trade.entry_price) * trade.size - trade.commission
|
|
782
|
+
trade.exit_equity = self.equity + trade.profit
|
|
783
|
+
if trade in self.open_trades:
|
|
784
|
+
self.open_trades.remove(trade)
|
|
785
|
+
self.closed_trades.append(trade)
|
|
786
|
+
self.new_closed_trades.append(trade)
|
|
787
|
+
self.closed_trades_count += 1
|