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,645 @@
|
|
|
1
|
+
"""Small reference venue used by examples and core conformance scenarios."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from dataclasses import dataclass, field, replace
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pynecore.core.broker.exceptions import (
|
|
8
|
+
ExchangeConnectionError,
|
|
9
|
+
ExchangeOrderRejectedError,
|
|
10
|
+
OrderSkippedByPlugin,
|
|
11
|
+
)
|
|
12
|
+
from pynecore.core.broker.models import (
|
|
13
|
+
CapabilityLevel,
|
|
14
|
+
DispatchEnvelope,
|
|
15
|
+
ExchangeCapabilities,
|
|
16
|
+
ExchangeOrder,
|
|
17
|
+
ExchangePosition,
|
|
18
|
+
INTENT_KEY_SEP,
|
|
19
|
+
LegType,
|
|
20
|
+
OrderEvent,
|
|
21
|
+
OrderStatus,
|
|
22
|
+
OrderType,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .model import Step
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class VenueOrder:
|
|
30
|
+
"""Reference venue ownership record."""
|
|
31
|
+
|
|
32
|
+
order: ExchangeOrder
|
|
33
|
+
run_name: str
|
|
34
|
+
pine_id: str
|
|
35
|
+
leg_type: LegType
|
|
36
|
+
intent_key: str
|
|
37
|
+
from_entry: str | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class VenueState:
|
|
42
|
+
"""Shared account state for one or more reference brokers."""
|
|
43
|
+
|
|
44
|
+
symbol: str = "LABUSD"
|
|
45
|
+
orders: dict[str, VenueOrder] = field(default_factory=dict)
|
|
46
|
+
position: float = 0.0
|
|
47
|
+
position_owners: dict[str, float] = field(default_factory=dict)
|
|
48
|
+
position_legs: dict[tuple[str, str], float] = field(default_factory=dict)
|
|
49
|
+
pending_events: list[tuple[str, OrderEvent]] = field(default_factory=list)
|
|
50
|
+
calls: list[tuple[str, str, str]] = field(default_factory=list)
|
|
51
|
+
next_id: int = 1
|
|
52
|
+
reject_entries: int = 0
|
|
53
|
+
skip_entries: int = 0
|
|
54
|
+
read_error: Exception | None = None
|
|
55
|
+
stale_position: float | None = None
|
|
56
|
+
terminal_snapshots: dict[str, tuple[OrderStatus, float, float]] = field(
|
|
57
|
+
default_factory=dict
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def new_id(self) -> str:
|
|
61
|
+
value = f"lab-{self.next_id}"
|
|
62
|
+
self.next_id += 1
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ReferenceBroker:
|
|
67
|
+
"""BrokerPlugin-shaped adapter over :class:`VenueState`."""
|
|
68
|
+
|
|
69
|
+
client_order_id_max_len = 30
|
|
70
|
+
on_unexpected_cancel = "stop"
|
|
71
|
+
position_port = None
|
|
72
|
+
|
|
73
|
+
def __init__(self, profile: "ReferenceVenueProfile", run_name: str) -> None:
|
|
74
|
+
self.profile = profile
|
|
75
|
+
self.state = profile.state
|
|
76
|
+
self.run_name = run_name
|
|
77
|
+
self.store_ctx: Any = None
|
|
78
|
+
|
|
79
|
+
def get_capabilities(self) -> ExchangeCapabilities:
|
|
80
|
+
return ExchangeCapabilities(short_selling=CapabilityLevel.NATIVE)
|
|
81
|
+
|
|
82
|
+
def _place(
|
|
83
|
+
self, envelope: DispatchEnvelope, leg: LegType, kind: str
|
|
84
|
+
) -> list[ExchangeOrder]:
|
|
85
|
+
self.state.calls.append((self.run_name, kind, envelope.intent.intent_key))
|
|
86
|
+
if leg is LegType.ENTRY and self.state.skip_entries:
|
|
87
|
+
self.state.skip_entries -= 1
|
|
88
|
+
raise OrderSkippedByPlugin(
|
|
89
|
+
"reference profile skipped entry",
|
|
90
|
+
intent_key=envelope.intent.intent_key,
|
|
91
|
+
reason="below_min_size",
|
|
92
|
+
)
|
|
93
|
+
if leg is LegType.ENTRY and self.state.reject_entries:
|
|
94
|
+
self.state.reject_entries -= 1
|
|
95
|
+
raise ExchangeOrderRejectedError("reference profile rejected entry")
|
|
96
|
+
intent = envelope.intent
|
|
97
|
+
order = ExchangeOrder(
|
|
98
|
+
id=self.state.new_id(),
|
|
99
|
+
symbol=self.profile.wire_symbol,
|
|
100
|
+
side=getattr(intent, "side", "buy"),
|
|
101
|
+
order_type=(
|
|
102
|
+
OrderType.STOP
|
|
103
|
+
if getattr(intent, "stop", None) is not None
|
|
104
|
+
else (
|
|
105
|
+
OrderType.LIMIT
|
|
106
|
+
if getattr(intent, "limit", None) is not None
|
|
107
|
+
else OrderType.MARKET
|
|
108
|
+
)
|
|
109
|
+
),
|
|
110
|
+
qty=float(getattr(intent, "qty", 0.0)),
|
|
111
|
+
filled_qty=0.0,
|
|
112
|
+
remaining_qty=float(getattr(intent, "qty", 0.0)),
|
|
113
|
+
price=getattr(intent, "limit", None),
|
|
114
|
+
stop_price=getattr(intent, "stop", None),
|
|
115
|
+
average_fill_price=None,
|
|
116
|
+
status=OrderStatus.OPEN,
|
|
117
|
+
timestamp=0.0,
|
|
118
|
+
fee=0.0,
|
|
119
|
+
fee_currency="",
|
|
120
|
+
client_order_id=envelope.client_order_id(kind),
|
|
121
|
+
)
|
|
122
|
+
pine_id = str(getattr(intent, "pine_id", ""))
|
|
123
|
+
if self.store_ctx is not None:
|
|
124
|
+
self.store_ctx.upsert_order(
|
|
125
|
+
order.client_order_id,
|
|
126
|
+
symbol=order.symbol,
|
|
127
|
+
side=order.side,
|
|
128
|
+
qty=order.qty,
|
|
129
|
+
state="confirmed",
|
|
130
|
+
intent_key=intent.intent_key,
|
|
131
|
+
exchange_order_id=order.id,
|
|
132
|
+
from_entry=getattr(intent, "from_entry", None),
|
|
133
|
+
pine_entry_id=pine_id or None,
|
|
134
|
+
filled_qty=0.0,
|
|
135
|
+
)
|
|
136
|
+
self.state.orders[order.id] = VenueOrder(
|
|
137
|
+
order=order,
|
|
138
|
+
run_name=self.run_name,
|
|
139
|
+
pine_id=pine_id,
|
|
140
|
+
leg_type=leg,
|
|
141
|
+
intent_key=intent.intent_key,
|
|
142
|
+
from_entry=getattr(intent, "from_entry", None),
|
|
143
|
+
)
|
|
144
|
+
return [order]
|
|
145
|
+
|
|
146
|
+
async def execute_entry(self, envelope: DispatchEnvelope) -> list[ExchangeOrder]:
|
|
147
|
+
return self._place(envelope, LegType.ENTRY, "e")
|
|
148
|
+
|
|
149
|
+
async def execute_exit(self, envelope: DispatchEnvelope) -> list[ExchangeOrder]:
|
|
150
|
+
intent = envelope.intent
|
|
151
|
+
limit = getattr(intent, "limit", None)
|
|
152
|
+
stop = getattr(intent, "stop", None)
|
|
153
|
+
orders: list[ExchangeOrder] = []
|
|
154
|
+
if limit is not None:
|
|
155
|
+
take_profit = self._place(envelope, LegType.TAKE_PROFIT, "t")[0]
|
|
156
|
+
take_profit = replace(
|
|
157
|
+
take_profit,
|
|
158
|
+
order_type=OrderType.LIMIT,
|
|
159
|
+
price=limit,
|
|
160
|
+
stop_price=None,
|
|
161
|
+
)
|
|
162
|
+
self.state.orders[take_profit.id].order = take_profit
|
|
163
|
+
orders.append(take_profit)
|
|
164
|
+
if stop is not None:
|
|
165
|
+
stop_loss = self._place(envelope, LegType.STOP_LOSS, "s")[0]
|
|
166
|
+
stop_loss = replace(
|
|
167
|
+
stop_loss,
|
|
168
|
+
order_type=OrderType.STOP,
|
|
169
|
+
price=None,
|
|
170
|
+
stop_price=stop,
|
|
171
|
+
)
|
|
172
|
+
self.state.orders[stop_loss.id].order = stop_loss
|
|
173
|
+
orders.append(stop_loss)
|
|
174
|
+
return orders or self._place(envelope, LegType.TAKE_PROFIT, "t")
|
|
175
|
+
|
|
176
|
+
async def execute_close(self, envelope: DispatchEnvelope) -> ExchangeOrder:
|
|
177
|
+
return self._place(envelope, LegType.CLOSE, "c")[0]
|
|
178
|
+
|
|
179
|
+
async def execute_cancel(self, envelope: DispatchEnvelope) -> bool:
|
|
180
|
+
self.state.calls.append((self.run_name, "cancel", envelope.intent.intent_key))
|
|
181
|
+
for venue_order in self.state.orders.values():
|
|
182
|
+
if (
|
|
183
|
+
venue_order.run_name == self.run_name
|
|
184
|
+
and venue_order.intent_key == envelope.intent.intent_key
|
|
185
|
+
and venue_order.order.status
|
|
186
|
+
in (OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED)
|
|
187
|
+
):
|
|
188
|
+
venue_order.order = replace(
|
|
189
|
+
venue_order.order,
|
|
190
|
+
status=OrderStatus.CANCELLED,
|
|
191
|
+
remaining_qty=0.0,
|
|
192
|
+
)
|
|
193
|
+
return True
|
|
194
|
+
|
|
195
|
+
async def modify_entry(
|
|
196
|
+
self,
|
|
197
|
+
old: DispatchEnvelope,
|
|
198
|
+
new: DispatchEnvelope,
|
|
199
|
+
) -> list[ExchangeOrder]:
|
|
200
|
+
await self.execute_cancel(old)
|
|
201
|
+
return await self.execute_entry(new)
|
|
202
|
+
|
|
203
|
+
async def modify_exit(
|
|
204
|
+
self,
|
|
205
|
+
old: DispatchEnvelope,
|
|
206
|
+
new: DispatchEnvelope,
|
|
207
|
+
) -> list[ExchangeOrder]:
|
|
208
|
+
await self.execute_cancel(old)
|
|
209
|
+
return await self.execute_exit(new)
|
|
210
|
+
|
|
211
|
+
async def get_open_orders(self, symbol: str | None = None) -> list[ExchangeOrder]:
|
|
212
|
+
if self.state.read_error is not None:
|
|
213
|
+
error = self.state.read_error
|
|
214
|
+
self.state.read_error = None
|
|
215
|
+
raise error
|
|
216
|
+
return [
|
|
217
|
+
record.order
|
|
218
|
+
for record in self.state.orders.values()
|
|
219
|
+
if record.run_name == self.run_name
|
|
220
|
+
and record.order.status is OrderStatus.OPEN
|
|
221
|
+
and (
|
|
222
|
+
symbol is None
|
|
223
|
+
or symbol == self.profile.symbol
|
|
224
|
+
or record.order.symbol == symbol
|
|
225
|
+
)
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
async def get_position(self, symbol: str) -> ExchangePosition | None:
|
|
229
|
+
if self.state.read_error is not None:
|
|
230
|
+
error = self.state.read_error
|
|
231
|
+
self.state.read_error = None
|
|
232
|
+
raise error
|
|
233
|
+
size = (
|
|
234
|
+
self.state.position
|
|
235
|
+
if self.state.stale_position is None
|
|
236
|
+
else self.state.stale_position
|
|
237
|
+
)
|
|
238
|
+
if size == 0.0:
|
|
239
|
+
return None
|
|
240
|
+
return ExchangePosition(
|
|
241
|
+
symbol=symbol,
|
|
242
|
+
side="long" if size > 0 else "short",
|
|
243
|
+
size=abs(size),
|
|
244
|
+
entry_price=100.0,
|
|
245
|
+
unrealized_pnl=0.0,
|
|
246
|
+
liquidation_price=None,
|
|
247
|
+
leverage=1.0,
|
|
248
|
+
margin_mode="cross",
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def watch_orders(self):
|
|
252
|
+
raise NotImplementedError
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class ReferenceVenueProfile:
|
|
256
|
+
"""Deterministic netting profile suitable for copied plugin examples."""
|
|
257
|
+
|
|
258
|
+
plugin_name = "broker-lab-reference"
|
|
259
|
+
account_id = "offline-account"
|
|
260
|
+
symbol = "LABUSD"
|
|
261
|
+
wire_symbol = "LABUSD"
|
|
262
|
+
timeframe = "1"
|
|
263
|
+
quantity_step = 1.0
|
|
264
|
+
venue_mode = "netting"
|
|
265
|
+
|
|
266
|
+
def __init__(self) -> None:
|
|
267
|
+
self.state = VenueState(symbol=self.symbol)
|
|
268
|
+
|
|
269
|
+
def create_broker(self, run_name: str, store_ctx: Any) -> ReferenceBroker:
|
|
270
|
+
broker = ReferenceBroker(self, run_name)
|
|
271
|
+
broker.store_ctx = store_ctx
|
|
272
|
+
return broker
|
|
273
|
+
|
|
274
|
+
def handle_step(self, runner: Any, step: Step) -> bool:
|
|
275
|
+
if step.kind == "fill":
|
|
276
|
+
self._fill(runner, step)
|
|
277
|
+
elif step.kind in ("deliver", "delayed_deliver"):
|
|
278
|
+
self._deliver(runner, step)
|
|
279
|
+
elif step.kind == "duplicate_event":
|
|
280
|
+
if not self.state.pending_events:
|
|
281
|
+
raise ValueError("duplicate_event requires a pending event")
|
|
282
|
+
self.state.pending_events.append(self.state.pending_events[-1])
|
|
283
|
+
elif step.kind == "reorder_events":
|
|
284
|
+
self.state.pending_events.reverse()
|
|
285
|
+
elif step.kind == "drop_events":
|
|
286
|
+
self.state.pending_events.clear()
|
|
287
|
+
elif step.kind == "reject_entries":
|
|
288
|
+
self.state.reject_entries = int(step.values.get("count", 1))
|
|
289
|
+
elif step.kind == "skip_entries":
|
|
290
|
+
self.state.skip_entries = int(step.values.get("count", 1))
|
|
291
|
+
elif step.kind == "read_error":
|
|
292
|
+
self.state.read_error = ExchangeConnectionError("injected read failure")
|
|
293
|
+
elif step.kind == "stale_position":
|
|
294
|
+
self.state.stale_position = float(step.values.get("position", 0.0))
|
|
295
|
+
elif step.kind == "fresh_position":
|
|
296
|
+
self.state.stale_position = None
|
|
297
|
+
elif step.kind == "venue_cancel":
|
|
298
|
+
self._venue_cancel(step)
|
|
299
|
+
elif step.kind == "expect":
|
|
300
|
+
self._expect(runner, step)
|
|
301
|
+
else:
|
|
302
|
+
return False
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
def _select_order(self, step: Step) -> VenueOrder:
|
|
306
|
+
order_id = step.values.get("order_id")
|
|
307
|
+
candidates = [
|
|
308
|
+
record
|
|
309
|
+
for record in self.state.orders.values()
|
|
310
|
+
if record.run_name == step.run
|
|
311
|
+
and (order_id is None or record.order.id == order_id)
|
|
312
|
+
and record.order.status in (OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED)
|
|
313
|
+
]
|
|
314
|
+
if not candidates:
|
|
315
|
+
raise ValueError(f"no open venue order for run {step.run!r}")
|
|
316
|
+
return candidates[-1]
|
|
317
|
+
|
|
318
|
+
def _fill(self, runner: Any, step: Step) -> None:
|
|
319
|
+
record = self._select_order(step)
|
|
320
|
+
qty = float(step.values.get("qty", record.order.remaining_qty))
|
|
321
|
+
if qty <= 0.0 or qty > record.order.remaining_qty + 1e-12:
|
|
322
|
+
raise ValueError(
|
|
323
|
+
"fill qty must be positive and at most the remaining quantity"
|
|
324
|
+
)
|
|
325
|
+
remaining = max(0.0, record.order.remaining_qty - qty)
|
|
326
|
+
filled = record.order.filled_qty + qty
|
|
327
|
+
status = (
|
|
328
|
+
OrderStatus.FILLED if remaining == 0.0 else OrderStatus.PARTIALLY_FILLED
|
|
329
|
+
)
|
|
330
|
+
price = float(step.values.get("price", 100.0))
|
|
331
|
+
record.order = replace(
|
|
332
|
+
record.order,
|
|
333
|
+
filled_qty=filled,
|
|
334
|
+
remaining_qty=remaining,
|
|
335
|
+
average_fill_price=price,
|
|
336
|
+
status=status,
|
|
337
|
+
)
|
|
338
|
+
runtime = runner.runs[step.run]
|
|
339
|
+
runtime.store_ctx.set_filled(record.order.client_order_id, filled)
|
|
340
|
+
signed = qty if record.order.side == "buy" else -qty
|
|
341
|
+
if record.leg_type is LegType.ENTRY:
|
|
342
|
+
self.state.position_owners[record.run_name] = (
|
|
343
|
+
self.state.position_owners.get(record.run_name, 0.0) + signed
|
|
344
|
+
)
|
|
345
|
+
if self.venue_mode == "hedged":
|
|
346
|
+
key = (record.run_name, record.pine_id)
|
|
347
|
+
self.state.position_legs[key] = (
|
|
348
|
+
self.state.position_legs.get(key, 0.0) + signed
|
|
349
|
+
)
|
|
350
|
+
else:
|
|
351
|
+
current = self.state.position_owners.get(record.run_name, 0.0)
|
|
352
|
+
new_size = current + signed
|
|
353
|
+
if current != 0.0 and current * new_size < 0.0:
|
|
354
|
+
new_size = 0.0
|
|
355
|
+
self.state.position_owners[record.run_name] = new_size
|
|
356
|
+
if self.venue_mode == "hedged" and record.from_entry is not None:
|
|
357
|
+
key = (record.run_name, record.from_entry)
|
|
358
|
+
leg_size = self.state.position_legs.get(key, 0.0)
|
|
359
|
+
reduced = leg_size + signed
|
|
360
|
+
self.state.position_legs[key] = (
|
|
361
|
+
0.0 if leg_size * reduced < 0.0 else reduced
|
|
362
|
+
)
|
|
363
|
+
self.state.position = sum(self.state.position_owners.values())
|
|
364
|
+
event = OrderEvent(
|
|
365
|
+
order=record.order,
|
|
366
|
+
event_type="filled" if remaining == 0.0 else "partial",
|
|
367
|
+
fill_price=price,
|
|
368
|
+
fill_qty=qty,
|
|
369
|
+
timestamp=runner.now_ms / 1000.0,
|
|
370
|
+
pine_id=record.pine_id,
|
|
371
|
+
leg_type=record.leg_type,
|
|
372
|
+
fill_id=str(step.values.get("fill_id", f"{record.order.id}:{filled}")),
|
|
373
|
+
)
|
|
374
|
+
self.state.pending_events.append((record.run_name, event))
|
|
375
|
+
|
|
376
|
+
def _deliver(self, runner: Any, step: Step) -> None:
|
|
377
|
+
selected = [item for item in self.state.pending_events if item[0] == step.run]
|
|
378
|
+
self.state.pending_events = [
|
|
379
|
+
item for item in self.state.pending_events if item[0] != step.run
|
|
380
|
+
]
|
|
381
|
+
|
|
382
|
+
def dispatch() -> None:
|
|
383
|
+
for _, event in selected:
|
|
384
|
+
runtime = runner.runs[step.run]
|
|
385
|
+
runtime.engine.on_order_event(event)
|
|
386
|
+
if selected:
|
|
387
|
+
runner.runs[step.run].engine.apply_async_events()
|
|
388
|
+
|
|
389
|
+
delay_ms = int(step.values.get("delay_ms", 0))
|
|
390
|
+
if delay_ms:
|
|
391
|
+
runner.scheduler.schedule(delay_ms, dispatch)
|
|
392
|
+
else:
|
|
393
|
+
dispatch()
|
|
394
|
+
|
|
395
|
+
def _venue_cancel(self, step: Step) -> None:
|
|
396
|
+
record = self._select_order(step)
|
|
397
|
+
record.order = replace(
|
|
398
|
+
record.order, status=OrderStatus.CANCELLED, remaining_qty=0.0
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def _expect(self, runner: Any, step: Step) -> None:
|
|
402
|
+
values = step.values
|
|
403
|
+
if "calls" in values and len(self.state.calls) != int(values["calls"]):
|
|
404
|
+
raise AssertionError(
|
|
405
|
+
f"expected {values['calls']} broker calls, got {len(self.state.calls)}"
|
|
406
|
+
)
|
|
407
|
+
if "position" in values:
|
|
408
|
+
actual = self.state.position_owners.get(step.run, 0.0)
|
|
409
|
+
if abs(actual - float(values["position"])) > 1e-9:
|
|
410
|
+
raise AssertionError(
|
|
411
|
+
f"expected venue position {values['position']}, got {actual}"
|
|
412
|
+
)
|
|
413
|
+
if "account_position" in values:
|
|
414
|
+
actual = self.state.position
|
|
415
|
+
if abs(actual - float(values["account_position"])) > 1e-9:
|
|
416
|
+
raise AssertionError(
|
|
417
|
+
f"expected account position {values['account_position']}, got {actual}"
|
|
418
|
+
)
|
|
419
|
+
if "engine_position" in values:
|
|
420
|
+
actual = runner.runs[step.run].position.size
|
|
421
|
+
if abs(actual - float(values["engine_position"])) > 1e-9:
|
|
422
|
+
raise AssertionError(
|
|
423
|
+
f"expected engine position {values['engine_position']}, got {actual}"
|
|
424
|
+
)
|
|
425
|
+
if "open_orders" in values:
|
|
426
|
+
actual = sum(
|
|
427
|
+
record.run_name == step.run
|
|
428
|
+
and record.order.status
|
|
429
|
+
in (OrderStatus.OPEN, OrderStatus.PARTIALLY_FILLED)
|
|
430
|
+
for record in self.state.orders.values()
|
|
431
|
+
)
|
|
432
|
+
if actual != int(values["open_orders"]):
|
|
433
|
+
raise AssertionError(
|
|
434
|
+
f"expected {values['open_orders']} open orders, got {actual}"
|
|
435
|
+
)
|
|
436
|
+
if "total_orders" in values:
|
|
437
|
+
actual = sum(
|
|
438
|
+
record.run_name == step.run for record in self.state.orders.values()
|
|
439
|
+
)
|
|
440
|
+
if actual != int(values["total_orders"]):
|
|
441
|
+
raise AssertionError(
|
|
442
|
+
f"expected {values['total_orders']} total orders, got {actual}"
|
|
443
|
+
)
|
|
444
|
+
if values.get("distinct_order_ids"):
|
|
445
|
+
ids = [
|
|
446
|
+
record.order.id
|
|
447
|
+
for record in self.state.orders.values()
|
|
448
|
+
if record.run_name == step.run
|
|
449
|
+
]
|
|
450
|
+
if len(ids) != len(set(ids)):
|
|
451
|
+
raise AssertionError(f"venue order ids are not distinct: {ids}")
|
|
452
|
+
if "wire_symbols" in values:
|
|
453
|
+
actual_symbols = {
|
|
454
|
+
record.order.symbol
|
|
455
|
+
for record in self.state.orders.values()
|
|
456
|
+
if record.run_name == step.run
|
|
457
|
+
}
|
|
458
|
+
expected_symbols = set(values["wire_symbols"])
|
|
459
|
+
if actual_symbols != expected_symbols:
|
|
460
|
+
raise AssertionError(
|
|
461
|
+
f"expected wire symbols {expected_symbols}, got {actual_symbols}"
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def check_invariants(self, runner: Any) -> Sequence[str]:
|
|
465
|
+
violations: list[str] = []
|
|
466
|
+
live_coids: dict[str, str] = {}
|
|
467
|
+
active_intents: set[tuple[Any, ...]] = set()
|
|
468
|
+
call_counts: dict[tuple[str, str, str], int] = {}
|
|
469
|
+
for call in self.state.calls:
|
|
470
|
+
call_counts[call] = call_counts.get(call, 0) + 1
|
|
471
|
+
for call, count in call_counts.items():
|
|
472
|
+
if count > 3:
|
|
473
|
+
violations.append(
|
|
474
|
+
f"bounded retry exceeded for {call}: {count} dispatches"
|
|
475
|
+
)
|
|
476
|
+
for record in self.state.orders.values():
|
|
477
|
+
order = record.order
|
|
478
|
+
if record.run_name not in runner.runs:
|
|
479
|
+
violations.append(
|
|
480
|
+
f"order {order.id} is owned by unknown run {record.run_name}"
|
|
481
|
+
)
|
|
482
|
+
if order.qty < 0.0 or order.filled_qty < 0.0 or order.remaining_qty < 0.0:
|
|
483
|
+
violations.append(f"negative venue quantity on {order.id}")
|
|
484
|
+
if self.quantity_step > 0.0:
|
|
485
|
+
units = order.qty / self.quantity_step
|
|
486
|
+
if abs(units - round(units)) > 1e-8:
|
|
487
|
+
violations.append(
|
|
488
|
+
f"quantity-grid residual on {order.id}: qty={order.qty} "
|
|
489
|
+
f"step={self.quantity_step}"
|
|
490
|
+
)
|
|
491
|
+
if (
|
|
492
|
+
order.status in (OrderStatus.FILLED, OrderStatus.CANCELLED)
|
|
493
|
+
and order.remaining_qty != 0.0
|
|
494
|
+
):
|
|
495
|
+
violations.append(
|
|
496
|
+
f"terminal order {order.id} retains remaining quantity"
|
|
497
|
+
)
|
|
498
|
+
terminal = self.state.terminal_snapshots.get(order.id)
|
|
499
|
+
if terminal is not None:
|
|
500
|
+
if order.status not in (
|
|
501
|
+
OrderStatus.FILLED,
|
|
502
|
+
OrderStatus.CANCELLED,
|
|
503
|
+
OrderStatus.REJECTED,
|
|
504
|
+
):
|
|
505
|
+
violations.append(
|
|
506
|
+
f"terminal order {order.id} resurrected as {order.status.value}"
|
|
507
|
+
)
|
|
508
|
+
elif terminal != (order.status, order.filled_qty, order.qty):
|
|
509
|
+
violations.append(
|
|
510
|
+
f"terminal order {order.id} was modified after terminalization"
|
|
511
|
+
)
|
|
512
|
+
elif order.status in (
|
|
513
|
+
OrderStatus.FILLED,
|
|
514
|
+
OrderStatus.CANCELLED,
|
|
515
|
+
OrderStatus.REJECTED,
|
|
516
|
+
):
|
|
517
|
+
self.state.terminal_snapshots[order.id] = (
|
|
518
|
+
order.status,
|
|
519
|
+
order.filled_qty,
|
|
520
|
+
order.qty,
|
|
521
|
+
)
|
|
522
|
+
if order.client_order_id and order.status in (
|
|
523
|
+
OrderStatus.OPEN,
|
|
524
|
+
OrderStatus.PARTIALLY_FILLED,
|
|
525
|
+
):
|
|
526
|
+
previous = live_coids.setdefault(
|
|
527
|
+
order.client_order_id, record.intent_key
|
|
528
|
+
)
|
|
529
|
+
if previous != record.intent_key:
|
|
530
|
+
violations.append(
|
|
531
|
+
f"client order id {order.client_order_id} aliases two intents"
|
|
532
|
+
)
|
|
533
|
+
intent_owner = (
|
|
534
|
+
record.run_name,
|
|
535
|
+
record.intent_key,
|
|
536
|
+
record.leg_type,
|
|
537
|
+
order.side,
|
|
538
|
+
order.qty,
|
|
539
|
+
order.price,
|
|
540
|
+
order.stop_price,
|
|
541
|
+
)
|
|
542
|
+
if intent_owner in active_intents:
|
|
543
|
+
violations.append(
|
|
544
|
+
f"economic idempotence violated for {record.run_name} "
|
|
545
|
+
f"intent={record.intent_key} leg={record.leg_type.value}"
|
|
546
|
+
)
|
|
547
|
+
active_intents.add(intent_owner)
|
|
548
|
+
if not self.state.pending_events:
|
|
549
|
+
owned_total = sum(self.state.position_owners.values())
|
|
550
|
+
if abs(owned_total - self.state.position) > 1e-9:
|
|
551
|
+
violations.append(
|
|
552
|
+
f"account position ownership mismatch: owners={owned_total} "
|
|
553
|
+
f"account={self.state.position}"
|
|
554
|
+
)
|
|
555
|
+
engine_total = sum(
|
|
556
|
+
runtime.position.size for runtime in runner.runs.values()
|
|
557
|
+
)
|
|
558
|
+
if abs(engine_total - self.state.position) > 1e-9:
|
|
559
|
+
violations.append(
|
|
560
|
+
f"economic account position mismatch: engines={engine_total} "
|
|
561
|
+
f"venue={self.state.position}"
|
|
562
|
+
)
|
|
563
|
+
owner_signs = {
|
|
564
|
+
1 if size > 0.0 else -1
|
|
565
|
+
for size in self.state.position_owners.values()
|
|
566
|
+
if abs(size) > 1e-9
|
|
567
|
+
}
|
|
568
|
+
if self.venue_mode == "netting" and len(owner_signs) > 1:
|
|
569
|
+
violations.append(
|
|
570
|
+
"opposing run ownership on a netting account cannot be isolated"
|
|
571
|
+
)
|
|
572
|
+
for run_name, runtime in runner.runs.items():
|
|
573
|
+
venue_size = self.state.position_owners.get(run_name, 0.0)
|
|
574
|
+
if abs(runtime.position.size - venue_size) > 1e-9:
|
|
575
|
+
violations.append(
|
|
576
|
+
f"economic position mismatch for {run_name}: "
|
|
577
|
+
f"engine={runtime.position.size} venue={venue_size}"
|
|
578
|
+
)
|
|
579
|
+
if self.venue_mode == "hedged":
|
|
580
|
+
leg_total = sum(
|
|
581
|
+
size
|
|
582
|
+
for (owner, _), size in self.state.position_legs.items()
|
|
583
|
+
if owner == run_name
|
|
584
|
+
)
|
|
585
|
+
if abs(leg_total - venue_size) > 1e-9:
|
|
586
|
+
violations.append(
|
|
587
|
+
f"hedged leg reconstruction mismatch for {run_name}: "
|
|
588
|
+
f"legs={leg_total} venue={venue_size}"
|
|
589
|
+
)
|
|
590
|
+
if abs(venue_size) <= 1e-9:
|
|
591
|
+
continue
|
|
592
|
+
required: dict[tuple[str | None, str], float] = {}
|
|
593
|
+
dispatched_intents = {
|
|
594
|
+
intent_key
|
|
595
|
+
for owner, _, intent_key in self.state.calls
|
|
596
|
+
if owner == run_name
|
|
597
|
+
}
|
|
598
|
+
for (
|
|
599
|
+
exit_id,
|
|
600
|
+
from_entry,
|
|
601
|
+
), logical_exit in runtime.position.exit_orders.items():
|
|
602
|
+
intent_key = f"{exit_id}{INTENT_KEY_SEP}{from_entry}"
|
|
603
|
+
if intent_key not in dispatched_intents:
|
|
604
|
+
continue
|
|
605
|
+
qty = abs(float(logical_exit.size))
|
|
606
|
+
if logical_exit.limit is not None:
|
|
607
|
+
key = (from_entry, "take-profit")
|
|
608
|
+
required[key] = required.get(key, 0.0) + qty
|
|
609
|
+
if logical_exit.stop is not None:
|
|
610
|
+
key = (from_entry, "stop-loss")
|
|
611
|
+
required[key] = required.get(key, 0.0) + qty
|
|
612
|
+
coverage: dict[tuple[str | None, str], float] = {}
|
|
613
|
+
for record in self.state.orders.values():
|
|
614
|
+
if record.run_name != run_name or record.order.status not in (
|
|
615
|
+
OrderStatus.OPEN,
|
|
616
|
+
OrderStatus.PARTIALLY_FILLED,
|
|
617
|
+
):
|
|
618
|
+
continue
|
|
619
|
+
if record.leg_type is LegType.TAKE_PROFIT:
|
|
620
|
+
outcome = "take-profit"
|
|
621
|
+
elif record.leg_type in (LegType.STOP_LOSS, LegType.TRAILING_STOP):
|
|
622
|
+
outcome = "stop-loss"
|
|
623
|
+
else:
|
|
624
|
+
continue
|
|
625
|
+
key = (record.from_entry, outcome)
|
|
626
|
+
coverage[key] = coverage.get(key, 0.0) + record.order.remaining_qty
|
|
627
|
+
for (from_entry, outcome), required_qty in required.items():
|
|
628
|
+
protected = coverage.get((from_entry, outcome), 0.0)
|
|
629
|
+
if protected + 1e-9 < required_qty:
|
|
630
|
+
violations.append(
|
|
631
|
+
f"{outcome} protection coverage shortfall for {run_name} "
|
|
632
|
+
f"from_entry={from_entry!r}: protected={protected} "
|
|
633
|
+
f"required={required_qty}"
|
|
634
|
+
)
|
|
635
|
+
return violations
|
|
636
|
+
|
|
637
|
+
def close(self) -> None:
|
|
638
|
+
self.state.pending_events.clear()
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class HedgedReferenceVenueProfile(ReferenceVenueProfile):
|
|
642
|
+
"""Reference venue retaining per-entry legs behind an aggregate position view."""
|
|
643
|
+
|
|
644
|
+
plugin_name = "broker-lab-reference-hedged"
|
|
645
|
+
venue_mode = "hedged"
|