gloomberb 0.18.1 → 0.18.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/package.json +1 -1
  2. package/src/api-client/commodity-balances.ts +6 -0
  3. package/src/api-client/company-kpis.ts +9 -0
  4. package/src/api-client/crypto-markets.ts +9 -0
  5. package/src/api-client/data.ts +6 -5
  6. package/src/api-client/equity-screener.ts +2 -0
  7. package/src/api-client/types.ts +5 -0
  8. package/src/app/pane-runtime/plugin-bindings.ts +3 -1
  9. package/src/app/pane-runtime/ticker-open-runtime.ts +17 -5
  10. package/src/app/runtime/plugin-bindings.ts +6 -2
  11. package/src/brokers/logos.ts +66 -0
  12. package/src/brokers/profile-form.ts +3 -0
  13. package/src/brokers/remote-broker-adapter.ts +13 -3
  14. package/src/capabilities/index.ts +1 -1
  15. package/src/capabilities/service-capabilities.ts +7 -1
  16. package/src/cli/base-converter.ts +60 -18
  17. package/src/cli/commands/market.ts +281 -154
  18. package/src/cli/commands/news-search.ts +35 -8
  19. package/src/cli/commands/system.ts +35 -1
  20. package/src/cli/commands/ticker.ts +100 -50
  21. package/src/cli/currency-hints.ts +27 -0
  22. package/src/cli/fx-pair.ts +41 -4
  23. package/src/cli/glossary.ts +57 -4
  24. package/src/cli/helpers.ts +36 -3
  25. package/src/cli/index.ts +4 -1
  26. package/src/cli/known-gaps.ts +37 -1
  27. package/src/cli/listing-arg.ts +37 -2
  28. package/src/cli/pane-functions/catalog.ts +3 -1
  29. package/src/cli/pane-functions/discovery.ts +15 -1
  30. package/src/cli/pane-functions/dom.ts +9 -4
  31. package/src/cli/pane-functions/freshness.ts +4 -4
  32. package/src/cli/pane-functions/function-help.ts +27 -3
  33. package/src/cli/pane-functions/headless.ts +6 -5
  34. package/src/cli/pane-functions/index.ts +3 -2
  35. package/src/cli/pane-functions/lock-note.ts +7 -4
  36. package/src/cli/pane-functions/resolver.ts +2 -1
  37. package/src/cli/quote-answer.ts +179 -0
  38. package/src/cli/series-search.ts +2 -1
  39. package/src/cli/types.ts +4 -0
  40. package/src/components/chart/composite/composite-chart.tsx +11 -2
  41. package/src/components/chart/composite/format.ts +13 -1
  42. package/src/components/chart/composite/level-labels.ts +30 -0
  43. package/src/components/chart/composite/levels.ts +17 -6
  44. package/src/components/chart/composite/panel-surface.tsx +58 -8
  45. package/src/components/command-bar/routes/ticker-search/actions.ts +6 -0
  46. package/src/components/index.ts +2 -0
  47. package/src/components/layout/pane/content.tsx +16 -14
  48. package/src/components/layout/pane/footer/index.tsx +22 -3
  49. package/src/components/layout/pane/footer/model.ts +99 -4
  50. package/src/components/layout/pane/header-tabs.tsx +15 -1
  51. package/src/components/onboarding/onboarding-frame.tsx +9 -11
  52. package/src/components/onboarding/use-onboarding-broker-form.ts +5 -2
  53. package/src/components/onboarding/wizard-model.ts +10 -3
  54. package/src/components/ui/button.tsx +11 -4
  55. package/src/components/ui/choice-dialog.tsx +8 -5
  56. package/src/components/ui/display.tsx +11 -6
  57. package/src/components/ui/fields.tsx +100 -20
  58. package/src/components/ui/icon.tsx +6 -1
  59. package/src/components/ui/list-row-lines.tsx +87 -0
  60. package/src/components/ui/list-view.tsx +14 -0
  61. package/src/components/ui/logo-mark.tsx +64 -0
  62. package/src/components/ui/number-step.ts +54 -0
  63. package/src/components/ui/select-button.tsx +5 -4
  64. package/src/components/ui/select-field.tsx +3 -3
  65. package/src/components/ui/status.tsx +17 -3
  66. package/src/components/ui/tabs.tsx +9 -1
  67. package/src/components/ui/toggle.tsx +17 -3
  68. package/src/core/state/app/instrument.ts +8 -3
  69. package/src/data/config/store/normalize.ts +4 -1
  70. package/src/data/plugin-cache.ts +32 -6
  71. package/src/docs/snapshot.json +5 -5
  72. package/src/i18n/es.ts +9 -3
  73. package/src/i18n/ja.ts +9 -3
  74. package/src/i18n/ko.ts +9 -3
  75. package/src/i18n/zh-cn.ts +10 -4
  76. package/src/i18n/zh-tw.ts +10 -4
  77. package/src/market-data/commodity-names.ts +80 -0
  78. package/src/market-data/coordinator/auxiliary.ts +3 -1
  79. package/src/market-data/coordinator/chart.ts +3 -1
  80. package/src/market-data/futures-board.ts +38 -1
  81. package/src/market-data/futures-contract.ts +91 -0
  82. package/src/market-data/fx-closes.ts +78 -1
  83. package/src/market-data/market/change-basis.ts +219 -0
  84. package/src/market-data/market/format.ts +28 -12
  85. package/src/market-data/market/freshness.ts +38 -0
  86. package/src/market-data/market/status.ts +55 -9
  87. package/src/market-data/market/year-to-date.ts +46 -0
  88. package/src/market-data/quotes/contributions.ts +12 -0
  89. package/src/market-data/quotes/delisting.ts +34 -0
  90. package/src/market-data/quotes/live-valuation.ts +23 -0
  91. package/src/market-data/quotes/resolution.ts +8 -2
  92. package/src/market-data/tax-equivalent.ts +10 -2
  93. package/src/notifications/app-notifier.ts +2 -2
  94. package/src/notifications/notification-log.ts +8 -6
  95. package/src/plugins/builtin/alerts/cli.ts +26 -18
  96. package/src/plugins/builtin/alerts/headless.ts +1 -1
  97. package/src/plugins/builtin/alerts/index.tsx +13 -4
  98. package/src/plugins/builtin/attention/trending.tsx +8 -6
  99. package/src/plugins/builtin/backtest/client.ts +5 -2
  100. package/src/plugins/builtin/backtest/window.ts +16 -3
  101. package/src/plugins/builtin/broker-manager/add-flow.tsx +40 -8
  102. package/src/plugins/builtin/broker-manager/detail.tsx +26 -1
  103. package/src/plugins/builtin/broker-manager/index.tsx +20 -9
  104. package/src/plugins/builtin/broker-manager/model.ts +5 -0
  105. package/src/plugins/builtin/broker-manager/pane-actions.tsx +8 -1
  106. package/src/plugins/builtin/broker-manager/table.ts +5 -1
  107. package/src/plugins/builtin/broker-trading/account-picker.tsx +91 -0
  108. package/src/plugins/builtin/broker-trading/chart-levels.ts +54 -0
  109. package/src/plugins/builtin/broker-trading/command.ts +103 -0
  110. package/src/plugins/builtin/broker-trading/controller.ts +688 -0
  111. package/src/plugins/builtin/broker-trading/dock.tsx +182 -0
  112. package/src/plugins/builtin/broker-trading/headless.ts +76 -0
  113. package/src/plugins/builtin/broker-trading/index.tsx +77 -0
  114. package/src/plugins/builtin/broker-trading/order-feed.ts +383 -0
  115. package/src/plugins/builtin/broker-trading/orders-data.ts +133 -0
  116. package/src/plugins/builtin/broker-trading/orders.tsx +437 -0
  117. package/src/plugins/builtin/broker-trading/overview-orders.tsx +121 -0
  118. package/src/plugins/builtin/broker-trading/pane-model.ts +207 -0
  119. package/src/plugins/builtin/broker-trading/pane.tsx +505 -0
  120. package/src/plugins/builtin/broker-trading/strategy.ts +153 -0
  121. package/src/plugins/builtin/broker-trading/ticket-parts.tsx +117 -0
  122. package/src/plugins/builtin/broker-trading/ticket.tsx +573 -0
  123. package/src/plugins/builtin/buildout/data-runtime.ts +1 -1
  124. package/src/plugins/builtin/buildout/format.ts +1 -0
  125. package/src/plugins/builtin/buildout/model/index.ts +16 -7
  126. package/src/plugins/builtin/buildout/pane/index.tsx +3 -2
  127. package/src/plugins/builtin/buildout/pane/status.tsx +3 -3
  128. package/src/plugins/builtin/chart-composer/cli-options.ts +19 -0
  129. package/src/plugins/builtin/chart-composer/data-catalog-pane.tsx +2 -1
  130. package/src/plugins/builtin/chart-composer/headless-schema.ts +3 -3
  131. package/src/plugins/builtin/chart-composer/headless.ts +2 -1
  132. package/src/plugins/builtin/chart-composer/option-range.ts +39 -0
  133. package/src/plugins/builtin/chart-composer/pane.tsx +31 -11
  134. package/src/plugins/builtin/chart-composer/presets.ts +2 -2
  135. package/src/plugins/builtin/chart-composer/series-catalog.ts +4 -2
  136. package/src/plugins/builtin/chart-composer/shortcuts.ts +3 -3
  137. package/src/plugins/builtin/chart-composer/viewport-labels.ts +5 -2
  138. package/src/plugins/builtin/chat/index.tsx +1 -1
  139. package/src/plugins/builtin/chat/status-widget.tsx +1 -1
  140. package/src/plugins/builtin/chat/unread-inbox-pane.tsx +6 -1
  141. package/src/plugins/builtin/chat/unread-inbox.ts +1 -1
  142. package/src/plugins/builtin/commodity-balances/client.ts +6 -4
  143. package/src/plugins/builtin/commodity-balances/headless.ts +41 -7
  144. package/src/plugins/builtin/commodity-balances/index.tsx +19 -4
  145. package/src/plugins/builtin/commodity-balances/model.ts +22 -0
  146. package/src/plugins/builtin/commodity-balances/pane.tsx +36 -5
  147. package/src/plugins/builtin/company-kpis/client.ts +3 -2
  148. package/src/plugins/builtin/company-kpis/headless.ts +5 -7
  149. package/src/plugins/builtin/company-kpis/model.ts +85 -2
  150. package/src/plugins/builtin/company-kpis/pane.tsx +26 -12
  151. package/src/plugins/builtin/composite-plugins.ts +3 -2
  152. package/src/plugins/builtin/cot/headless.ts +1 -1
  153. package/src/plugins/builtin/cot/index.tsx +1 -1
  154. package/src/plugins/builtin/cot/model.ts +35 -12
  155. package/src/plugins/builtin/credit-boards/cdx-pane.tsx +2 -1
  156. package/src/plugins/builtin/credit-boards/sovr-pane.tsx +2 -1
  157. package/src/plugins/builtin/credit-conditions/index.tsx +2 -1
  158. package/src/plugins/builtin/crypto-board/client.ts +33 -2
  159. package/src/plugins/builtin/crypto-board/headless.ts +5 -2
  160. package/src/plugins/builtin/crypto-board/model.ts +28 -8
  161. package/src/plugins/builtin/dividend-yield/headless.ts +30 -8
  162. package/src/plugins/builtin/dividend-yield/provider-client.ts +3 -1
  163. package/src/plugins/builtin/earnings-calls/headless.ts +4 -4
  164. package/src/plugins/builtin/earnings-calls/lock-note.ts +12 -3
  165. package/src/plugins/builtin/econ/calendar-model.ts +35 -0
  166. package/src/plugins/builtin/econ/index.tsx +31 -4
  167. package/src/plugins/builtin/econ/print-watch.ts +279 -0
  168. package/src/plugins/builtin/equity-screener/headless.ts +20 -5
  169. package/src/plugins/builtin/equity-screener/model.ts +40 -2
  170. package/src/plugins/builtin/equity-screener/pane.tsx +24 -21
  171. package/src/plugins/builtin/estimate-revisions/headless.ts +3 -2
  172. package/src/plugins/builtin/estimate-revisions/lock-note.ts +29 -7
  173. package/src/plugins/builtin/exposure/client.ts +4 -1
  174. package/src/plugins/builtin/exposure/editor.tsx +3 -3
  175. package/src/plugins/builtin/exposure/evidence.ts +4 -1
  176. package/src/plugins/builtin/exposure/holdings.ts +3 -1
  177. package/src/plugins/builtin/exposure/pane.tsx +37 -16
  178. package/src/plugins/builtin/filing-events/index.tsx +9 -3
  179. package/src/plugins/builtin/futures/table.tsx +6 -3
  180. package/src/plugins/builtin/futures-curve/pane.tsx +2 -2
  181. package/src/plugins/builtin/insider/base-values.ts +72 -0
  182. package/src/plugins/builtin/insider/headless.ts +29 -4
  183. package/src/plugins/builtin/jobs/headless.ts +2 -2
  184. package/src/plugins/builtin/jobs/lock-note.ts +14 -3
  185. package/src/plugins/builtin/market-halts/model.ts +31 -6
  186. package/src/plugins/builtin/market-halts/pane.tsx +3 -0
  187. package/src/plugins/builtin/market-valuation/pane.tsx +2 -1
  188. package/src/plugins/builtin/members/pane.tsx +8 -3
  189. package/src/plugins/builtin/news/wire/feed-config.ts +29 -12
  190. package/src/plugins/builtin/news/wire/index.tsx +7 -2
  191. package/src/plugins/builtin/news/wire/news/preset-pane.tsx +11 -3
  192. package/src/plugins/builtin/news/wire/rss/cli.ts +3 -2
  193. package/src/plugins/builtin/news/wire/rss/source.ts +13 -4
  194. package/src/plugins/builtin/options/footer.ts +25 -20
  195. package/src/plugins/builtin/options/live-quotes.ts +33 -18
  196. package/src/plugins/builtin/options/view.tsx +77 -22
  197. package/src/plugins/builtin/options-calculator/pane.tsx +4 -2
  198. package/src/plugins/builtin/options-scenario/pane.tsx +41 -6
  199. package/src/plugins/builtin/portfolio-list/account-metrics.ts +3 -0
  200. package/src/plugins/builtin/portfolio-list/cli/render.ts +34 -10
  201. package/src/plugins/builtin/portfolio-list/headless.ts +1 -1
  202. package/src/plugins/builtin/portfolio-list/pane/data.ts +23 -1
  203. package/src/plugins/builtin/portfolio-list/pane/index.tsx +33 -14
  204. package/src/plugins/builtin/portfolio-list/settings.ts +20 -2
  205. package/src/plugins/builtin/portfolio-list/summary/index.tsx +12 -0
  206. package/src/plugins/builtin/portfolio-list/summary/totals.ts +24 -3
  207. package/src/plugins/builtin/primary-dealers/client.ts +137 -62
  208. package/src/plugins/builtin/primary-dealers/headless.ts +30 -5
  209. package/src/plugins/builtin/primary-dealers/index.tsx +3 -2
  210. package/src/plugins/builtin/primary-dealers/model.ts +179 -129
  211. package/src/plugins/builtin/primary-dealers/pane.tsx +135 -15
  212. package/src/plugins/builtin/research/analyst-model.ts +5 -1
  213. package/src/plugins/builtin/risk-factors/index.tsx +12 -3
  214. package/src/plugins/builtin/scanner/feed.ts +6 -1
  215. package/src/plugins/builtin/scanner/headless.ts +1 -1
  216. package/src/plugins/builtin/scanner/lock-note.ts +3 -2
  217. package/src/plugins/builtin/series-inventory.ts +1 -0
  218. package/src/plugins/builtin/shared/cloud-denied.ts +7 -6
  219. package/src/plugins/builtin/shared/cloud-upgrade.ts +10 -11
  220. package/src/plugins/builtin/shared/free-routes.ts +24 -0
  221. package/src/plugins/builtin/shared/headless-options.ts +11 -0
  222. package/src/plugins/builtin/shared/pro-lock-note.ts +89 -7
  223. package/src/plugins/builtin/shared/research-trading.tsx +62 -0
  224. package/src/plugins/builtin/social-mentions/headless.ts +1 -1
  225. package/src/plugins/builtin/social-mentions/lock-note.ts +1 -1
  226. package/src/plugins/builtin/themes/headless.ts +8 -2
  227. package/src/plugins/builtin/themes/model.ts +43 -10
  228. package/src/plugins/builtin/themes/pane.tsx +8 -6
  229. package/src/plugins/builtin/ticker-detail/overview/model.ts +44 -10
  230. package/src/plugins/builtin/ticker-detail/overview/option-contract.ts +64 -0
  231. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +82 -21
  232. package/src/plugins/builtin/ticker-detail/pane.tsx +21 -14
  233. package/src/plugins/builtin/ticker-detail/quote-footer.ts +5 -1
  234. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +37 -8
  235. package/src/plugins/builtin/time-sales/pane.tsx +2 -2
  236. package/src/plugins/builtin/trace-bonds/client.ts +14 -3
  237. package/src/plugins/builtin/trace-bonds/headless.ts +1 -1
  238. package/src/plugins/builtin/vol-surface/pane.tsx +2 -2
  239. package/src/plugins/builtin-editorial.ts +1 -1
  240. package/src/plugins/compat.ts +3 -0
  241. package/src/plugins/external-runtime.ts +5 -0
  242. package/src/plugins/loader.ts +6 -0
  243. package/src/plugins/plugin-icon.ts +61 -0
  244. package/src/plugins/registry/host-actions.ts +2 -1
  245. package/src/plugins/registry/index.ts +9 -1
  246. package/src/plugins/runtime/context.tsx +5 -0
  247. package/src/plugins/runtime/index.tsx +3 -0
  248. package/src/portfolio/column-values.ts +13 -4
  249. package/src/portfolio/cost-fx.ts +5 -28
  250. package/src/public/__snapshots__/public-api.test.ts.snap +5 -0
  251. package/src/public/broker.ts +8 -0
  252. package/src/public/market-data.ts +5 -0
  253. package/src/renderers/dom/desktop/control-styles.ts +7 -1
  254. package/src/renderers/dom/desktop/controls.tsx +39 -7
  255. package/src/renderers/dom/desktop/icons.tsx +14 -0
  256. package/src/renderers/dom/desktop/list-view.tsx +51 -19
  257. package/src/renderers/dom/desktop/menu.tsx +10 -1
  258. package/src/renderers/dom/desktop/select-field.tsx +8 -6
  259. package/src/renderers/dom/host/style.ts +6 -1
  260. package/src/renderers/dom/host/tabs.tsx +6 -0
  261. package/src/renderers/dom/styles.css +12 -5
  262. package/src/renderers/electrobun/bun/core-capabilities.ts +12 -0
  263. package/src/renderers/electrobun/bun/external-plugins.ts +3 -0
  264. package/src/renderers/electrobun/shared/protocol.ts +2 -0
  265. package/src/renderers/electrobun/view/broker-remote-client.ts +16 -0
  266. package/src/renderers/electrobun/view/external-plugins.ts +1 -0
  267. package/src/series/validate.ts +2 -2
  268. package/src/sources/gloomberb-cloud/index.ts +50 -17
  269. package/src/sources/gloomberb-cloud/normalizers.ts +4 -0
  270. package/src/sources/history-result.ts +3 -0
  271. package/src/sources/provider-errors.ts +3 -1
  272. package/src/sources/provider-router/history.ts +49 -6
  273. package/src/sources/provider-router/index.ts +2 -1
  274. package/src/state/app/activity.ts +13 -0
  275. package/src/sync/controller.ts +4 -0
  276. package/src/sync/core-contributors.ts +62 -11
  277. package/src/sync/local-only.ts +121 -0
  278. package/src/sync/react.ts +6 -0
  279. package/src/sync/types.ts +3 -0
  280. package/src/tickers/listing-fate.ts +27 -8
  281. package/src/tickers/search/index.ts +24 -3
  282. package/src/time-series/price-comparison.ts +35 -0
  283. package/src/time-series/resolve.ts +52 -11
  284. package/src/time-series/spec.ts +1 -1
  285. package/src/time-series/types.ts +5 -0
  286. package/src/types/broker.ts +56 -1
  287. package/src/types/config.ts +6 -0
  288. package/src/types/data-provider.ts +5 -1
  289. package/src/types/financials.ts +23 -0
  290. package/src/types/headless.ts +12 -2
  291. package/src/types/instrument.ts +4 -0
  292. package/src/types/plugin.ts +24 -6
  293. package/src/types/price-history.ts +6 -1
  294. package/src/types/trading.ts +67 -0
  295. package/src/ui/host.tsx +4 -0
  296. package/src/utils/base-currency.ts +108 -0
  297. package/src/utils/currency-names.ts +128 -0
  298. package/src/utils/currency-pair.ts +2 -1
  299. package/src/utils/currency-units.ts +32 -1
  300. package/src/utils/dom-screenshot.ts +22 -9
  301. package/src/utils/exchange-notation.generated.ts +360 -0
  302. package/src/utils/exchanges.ts +15 -322
  303. package/src/utils/format.ts +31 -1
  304. package/src/utils/instrument-identity.ts +38 -1
  305. package/src/utils/options.ts +46 -1
  306. package/src/utils/price-history.ts +4 -0
  307. package/src/utils/reported-money.ts +8 -4
@@ -0,0 +1,688 @@
1
+ import { matchesNumberStep } from "../../../components/ui/number-step";
2
+ import type { BrokerAdapter, BrokerConnectionStatus } from "../../../types/broker";
3
+ import type { BrokerInstanceConfig } from "../../../types/config";
4
+ import type { Quote } from "../../../types/financials";
5
+ import type { BrokerAccount, BrokerOrder, BrokerOrderPreview, BrokerOrderRequest, BrokerTradingCapabilities } from "../../../types/trading";
6
+ import { optionContractIdentity } from "../../../utils/options";
7
+ import { isStrategyOrder, strategyQuote } from "./strategy";
8
+ import { brokerOrderDone, brokerOrderStatus, orderEstimatePrice, ticketEditingPriceStep } from "./orders-data";
9
+
10
+ export interface BrokerTradingContext {
11
+ adapter: BrokerAdapter;
12
+ instance: BrokerInstanceConfig;
13
+ accounts: BrokerAccount[];
14
+ accountId?: string;
15
+ /** Fresh signed quantity for this exact profile, account and requested contract. */
16
+ availablePosition?: number;
17
+ connection: BrokerConnectionStatus;
18
+ /** Increment when a connection is replaced without an intermediate disconnected state. */
19
+ connectionRevision?: string | number;
20
+ }
21
+
22
+ export interface BrokerTicketQuote {
23
+ quote: Quote;
24
+ brokerId: string;
25
+ brokerInstanceId: string;
26
+ sourceLabel: string;
27
+ receivedAt: number;
28
+ delayed: boolean;
29
+ stale: boolean;
30
+ /** A spread's own legs' quotes, in leg order; `quote` is the spread's. */
31
+ legs?: Quote[];
32
+ }
33
+
34
+ interface BrokerOrderReview {
35
+ request: BrokerOrderRequest;
36
+ preview: BrokerOrderPreview;
37
+ warnings: string[];
38
+ account: BrokerAccount;
39
+ brokerName: string;
40
+ createdAt: number;
41
+ mode: "place" | "modify";
42
+ requiresTypedConfirmation: boolean;
43
+ confirmationText: string;
44
+ }
45
+
46
+ /** What became of an order whose change or cancel the broker refused: one line, which the view shows once. */
47
+ export interface BrokerOrderOutcome {
48
+ title: string;
49
+ type: "info" | "error";
50
+ /** The order as the broker now reports it, else as it was. */
51
+ order: BrokerOrder;
52
+ }
53
+
54
+ export interface BrokerTradingSnapshot {
55
+ phase: "editing" | "previewing" | "review" | "submitting" | "result" | "cancel-review" | "cancelling";
56
+ draft?: BrokerOrderRequest;
57
+ quote?: BrokerTicketQuote;
58
+ review?: BrokerOrderReview;
59
+ /** Read-only receipt of the consumed preview. Never authorizes another mutation. */
60
+ receipt?: BrokerOrderReview;
61
+ result?: BrokerOrder;
62
+ /** Quantity provenance, separate from the broker status. A lost acknowledgement confirms nothing. */
63
+ resultQuantities?: "confirmed" | "unconfirmed" | "last-known";
64
+ cancelOrder?: BrokerOrder;
65
+ error?: string;
66
+ loadingQuote?: boolean;
67
+ modifying?: boolean;
68
+ /** Opt-in is ephemeral and scoped to the current SIM profile/account. */
69
+ confirmOrders: boolean;
70
+ }
71
+
72
+ interface ReviewGate {
73
+ context: string;
74
+ request: string;
75
+ version: number;
76
+ quoteRisk: string;
77
+ order?: BrokerOrder;
78
+ }
79
+
80
+ const TERMINAL = new Set(["FILLED", "CANCELED", "CANCELLED", "REJECTED", "EXPIRED", "SUPERSEDED"]);
81
+ /** A streamed quote older than this is no longer being pushed: the ticket asks the broker instead. */
82
+ const STREAM_QUOTE_MAX_SILENCE_MS = 75_000;
83
+ /** A placement whose answer was lost: only the broker's open orders can say whether it went in. */
84
+ const uncertainPlacement = "Outcome unknown. Check open orders before placing it again.";
85
+ /** A change or cancel whose answer was lost: the views ask the broker until it says. */
86
+ const uncertainChange = "Outcome unknown. Checking with the broker.";
87
+ /** The broker's own words come first: they say more than "outcome unknown". */
88
+ const uncertainAfter = (error: unknown) => error instanceof Error && error.message && !error.message.includes(uncertainPlacement) ? `${error.message.trim().replace(/\.?$/, ".")} ${uncertainPlacement}` : uncertainPlacement;
89
+ /** The broker answered no, or nothing was sent (see BrokerAdapter.placeOrder): its one-line reason, else undefined. */
90
+ function refusal(error: unknown): string | undefined {
91
+ if (!(error instanceof Error) || (error as { refused?: unknown }).refused !== true) return undefined;
92
+ return error.message.trim().replace(/\.$/, "") || "The broker refused this action";
93
+ }
94
+
95
+ function copy<T>(value: T): T { return structuredClone(value); }
96
+ function freeze<T>(value: T): T {
97
+ if (value && typeof value === "object") {
98
+ for (const child of Object.values(value)) freeze(child);
99
+ Object.freeze(value);
100
+ }
101
+ return value;
102
+ }
103
+ function canonical(value: unknown): string {
104
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
105
+ if (value && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`).join(",")}}`;
106
+ return JSON.stringify(value) ?? "null";
107
+ }
108
+ /** The same broker order: by native ID when the result has one, else by its acknowledged handle. */
109
+ function sameOrder(order: BrokerOrder, result: BrokerOrder): boolean {
110
+ return result.brokerOrderId ? order.brokerOrderId === result.brokerOrderId : result.orderId !== 0 && order.orderId === result.orderId;
111
+ }
112
+ function positive(value: number | undefined): value is number { return value !== undefined && Number.isFinite(value) && value > 0; }
113
+ function errorMessage(error: unknown, fallback: string): string { return error instanceof Error && error.message ? error.message : fallback; }
114
+
115
+ function tradingCapabilities(context: BrokerTradingContext, request?: BrokerOrderRequest): BrokerTradingCapabilities {
116
+ return context.adapter.getTradingCapabilities?.(context.instance, request?.contract, request?.orderType) ?? {
117
+ enabled: false, disabledReason: "This broker has not enabled the shared order ticket.", orderTypes: [], tif: [],
118
+ };
119
+ }
120
+
121
+ /** All mutations pass through a consumed review or cancel confirmation. No method retries a mutation. */
122
+ export class BrokerTradingController {
123
+ private snapshot: BrokerTradingSnapshot = freeze({ phase: "editing", confirmOrders: true });
124
+ private readonly listeners = new Set<() => void>();
125
+ private readonly now: () => number;
126
+ private readonly staleQuoteMs: number;
127
+ private readonly reviewTtlMs: number;
128
+ private version = 0;
129
+ private quoteVersion = 0;
130
+ private gate?: ReviewGate;
131
+ private modifyOrder?: BrokerOrder;
132
+ private contextKey = "";
133
+ private identityKey = "";
134
+ private disposed = false;
135
+ private mutationPending = false;
136
+ /** The latest quote the broker's stream pushed per symbol, for the current instrument and its legs. */
137
+ private readonly streamQuotes = new Map<string, Quote>();
138
+ private streamed?: { contractKey: string; quote: BrokerTicketQuote };
139
+ /** The last failed quote request's message, which a streamed quote may clear. */
140
+ private quoteError?: string;
141
+ private entryPending = false;
142
+
143
+ constructor(private readonly options: {
144
+ getContext: (request?: BrokerOrderRequest) => BrokerTradingContext; now?: () => number; staleQuoteMs?: number; reviewTtlMs?: number;
145
+ /** A refused change or cancel, settled against the broker's current order. */
146
+ onOutcome?: (outcome: BrokerOrderOutcome) => void;
147
+ }) {
148
+ this.now = options.now ?? Date.now;
149
+ this.staleQuoteMs = options.staleQuoteMs ?? 60_000;
150
+ this.reviewTtlMs = options.reviewTtlMs ?? 60_000;
151
+ this.contextKey = this.key();
152
+ this.identityKey = this.key(true);
153
+ }
154
+
155
+ getSnapshot = (): BrokerTradingSnapshot => this.snapshot;
156
+ subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; };
157
+
158
+ private publish(update: Partial<BrokerTradingSnapshot>): void {
159
+ if (this.disposed) return;
160
+ this.snapshot = freeze({ ...this.snapshot, ...update });
161
+ for (const listener of this.listeners) listener();
162
+ }
163
+
164
+ private key(ignorePosition = false): string {
165
+ const context = this.options.getContext(this.snapshot.draft);
166
+ const account = context.accounts.find((item) => item.accountId === context.accountId);
167
+ // This private comparison includes configuration changes but is never exposed in state or errors.
168
+ return canonical({ broker: context.adapter.id, profile: context.instance.id, config: context.instance.config, enabled: context.instance.enabled,
169
+ account: context.accountId, mode: account?.tradingMode ?? "unknown", name: account?.name,
170
+ connection: context.connection.state, connectionMode: context.connection.mode, quoteData: context.connection.quoteData, revision: context.connectionRevision,
171
+ capabilities: tradingCapabilities(context, this.snapshot.draft),
172
+ availablePosition: !ignorePosition && this.snapshot.draft?.action === "SELL" ? context.availablePosition : undefined });
173
+ }
174
+
175
+ syncContext(): void {
176
+ if (this.disposed) return;
177
+ const next = this.key();
178
+ if (next === this.contextKey) return;
179
+ const nextIdentity = this.key(true);
180
+ const onlyPositionChanged = nextIdentity === this.identityKey;
181
+ this.identityKey = nextIdentity;
182
+ this.contextKey = next;
183
+ this.version++;
184
+ this.quoteVersion++;
185
+ this.gate = undefined;
186
+ if (onlyPositionChanged) {
187
+ if (!this.mutationPending && this.snapshot.phase !== "result") this.publish({ phase: "editing", review: undefined, loadingQuote: false, error: "The held position changed or became unavailable. Review the order again." });
188
+ return;
189
+ }
190
+ this.publish({ confirmOrders: true });
191
+ // Another profile, account or connection: quotes pushed before it no longer stand.
192
+ this.streamQuotes.clear();
193
+ this.streamed = undefined;
194
+ if (this.mutationPending) return;
195
+ this.modifyOrder = undefined;
196
+ // An order whose outcome is unknown stays the result through a reconnect or a refresh, until a refresh finds it:
197
+ // dropping it would let the same draft be reviewed and placed a second time.
198
+ if (this.uncertain()) {
199
+ this.publish({ review: undefined, cancelOrder: undefined, quote: undefined, loadingQuote: false, error: this.uncertainError() });
200
+ return;
201
+ }
202
+ const changed = this.snapshot.draft || this.snapshot.cancelOrder || this.snapshot.result;
203
+ this.publish({
204
+ phase: "editing", modifying: false, review: undefined, receipt: undefined, result: undefined, cancelOrder: undefined, quote: undefined, loadingQuote: false,
205
+ error: changed ? "Profile, account or connection changed. Review the order again." : undefined,
206
+ });
207
+ }
208
+
209
+ /** The last order action's outcome is unknown: the broker may hold the order. */
210
+ uncertain(): boolean {
211
+ return this.snapshot.result?.status.toUpperCase() === "UNKNOWN";
212
+ }
213
+
214
+ /** A lost placement asks the trader to check before placing again; a change the broker numbered is being checked. */
215
+ private uncertainError(): string {
216
+ return this.snapshot.result?.orderId ? uncertainChange : uncertainPlacement;
217
+ }
218
+
219
+ private editable(): void {
220
+ if (this.disposed) throw new Error("This order ticket is closed.");
221
+ if (this.mutationPending) throw new Error("An order action is already pending.");
222
+ if (this.uncertain()) throw new Error(this.uncertainError());
223
+ }
224
+
225
+ setDraft(request: BrokerOrderRequest): void {
226
+ this.editable();
227
+ const original = this.modifyOrder;
228
+ if (original && (canonical(request.contract) !== canonical(original.contract) || canonical(request.legs ?? null) !== canonical(original.legs ?? null)
229
+ || request.action !== original.action || request.accountId !== original.accountId || request.orderType !== original.orderType || request.tif !== original.tif)) {
230
+ throw new Error("A modification keeps the original account, instrument, legs, side, type and time in force.");
231
+ }
232
+ // A spread's legs are its instrument: other legs need another quote.
233
+ const changedContract = canonical([request.contract, request.legs ?? null]) !== canonical([this.snapshot.draft?.contract, this.snapshot.draft?.legs ?? null]);
234
+ this.version++;
235
+ this.gate = undefined;
236
+ if (changedContract) { this.quoteVersion++; this.modifyOrder = undefined; this.streamQuotes.clear(); this.streamed = undefined; }
237
+ this.publish({ phase: "editing", draft: copy(request), modifying: !!this.modifyOrder, review: undefined, receipt: undefined, cancelOrder: undefined, result: undefined, error: undefined,
238
+ ...(changedContract ? { quote: undefined, loadingQuote: false } : {}) });
239
+ this.contextKey = this.key();
240
+ this.identityKey = this.key(true);
241
+ }
242
+
243
+ private context(requireTrading = true, request = this.snapshot.draft): { context: BrokerTradingContext; account: BrokerAccount; capabilities: BrokerTradingCapabilities } {
244
+ this.syncContext();
245
+ if (this.disposed) throw new Error("This order ticket is closed.");
246
+ const context = this.options.getContext(request);
247
+ if (context.instance.enabled === false || context.connection.state !== "connected") throw new Error("Connect this broker profile before continuing.");
248
+ const capabilities = tradingCapabilities(context, request);
249
+ if (requireTrading && !capabilities.enabled) throw new Error(capabilities.disabledReason ?? "Trading is off. Enable it for this profile before continuing.");
250
+ if (!context.accountId) throw new Error("Choose an account explicitly before continuing.");
251
+ const account = context.accounts.find((item) => item.accountId === context.accountId);
252
+ if (!account) throw new Error("The selected account is unavailable. Choose an account again.");
253
+ return { context, account, capabilities };
254
+ }
255
+
256
+ private request(): BrokerOrderRequest {
257
+ const { context, capabilities } = this.context();
258
+ const request = this.snapshot.draft;
259
+ if (!request) throw new Error("Enter an order first.");
260
+ if (request.accountId !== context.accountId || (request.brokerInstanceId && request.brokerInstanceId !== context.instance.id)) throw new Error("The order must use the selected profile and account.");
261
+ if (request.contract.brokerId !== context.adapter.id || (request.contract.brokerInstanceId && request.contract.brokerInstanceId !== context.instance.id)) throw new Error("Choose an instrument from this broker profile.");
262
+ if (!request.contract.symbol.trim() || !["BUY", "SELL"].includes(request.action)) throw new Error("Choose an instrument and order side.");
263
+ if (!capabilities.orderTypes.includes(request.orderType)) throw new Error("This broker does not support the selected order type.");
264
+ if (!positive(request.quantity) || request.quantity < (capabilities.minQuantity ?? 0)) throw new Error("Enter a valid positive quantity.");
265
+ const strategy = isStrategyOrder(request);
266
+ if (strategy) this.checkLegs(request, context, capabilities);
267
+ // A spread's short legs are the broker's to check: its preview answers for margin and holdings.
268
+ if (request.action === "SELL" && !capabilities.shortSelling && !strategy) {
269
+ if (context.availablePosition === undefined || !Number.isFinite(context.availablePosition)) throw new Error("Refresh the position before selling. The held quantity is unavailable.");
270
+ if (request.quantity > Math.max(0, context.availablePosition)) throw new Error("Sell quantity exceeds the held position. This broker has not enabled short selling.");
271
+ }
272
+ if (!capabilities.fractionalQuantity && !Number.isInteger(request.quantity)) throw new Error("This instrument requires a whole quantity.");
273
+ if (capabilities.quantityStep !== undefined && !matchesNumberStep(request.quantity, capabilities.quantityStep)) throw new Error("Quantity does not match the broker's allowed increment.");
274
+ if (!capabilities.tif.includes(request.tif ?? "DAY")) throw new Error("This broker does not support the selected time in force.");
275
+ if (request.outsideRth && (!capabilities.extendedHours || (capabilities.extendedHoursTif && !capabilities.extendedHoursTif.includes(request.tif ?? "DAY")))) throw new Error("Extended hours are unavailable for this order and time in force.");
276
+ const limit = request.orderType === "LMT" || request.orderType === "STP LMT";
277
+ const stop = request.orderType === "STP" || request.orderType === "STP LMT";
278
+ for (const [needed, value, label] of [[limit, request.limitPrice, "limit"], [stop, request.stopPrice, "stop"]] as const) {
279
+ if (needed && !positive(value)) throw new Error(`Enter a valid ${label} price.`);
280
+ if (!needed && value !== undefined) throw new Error(`Remove the ${label} price for this order type.`);
281
+ const decimals = capabilities.priceDecimals;
282
+ if (value !== undefined && decimals !== undefined && (!Number.isInteger(decimals) || decimals < 0 || decimals > 18 || Number(value.toFixed(decimals)) !== value)) {
283
+ throw new Error("Price precision exceeds the broker's limit.");
284
+ }
285
+ if (value !== undefined && capabilities.priceStep !== undefined && !matchesNumberStep(value, capabilities.priceStep)) throw new Error("Price does not match the broker's allowed increment.");
286
+ }
287
+ return copy(request);
288
+ }
289
+
290
+ private checkLegs(request: BrokerOrderRequest, context: BrokerTradingContext, capabilities: BrokerTradingCapabilities): void {
291
+ const legs = request.legs ?? [];
292
+ const maxLegs = capabilities.maxLegs ?? 0;
293
+ if (maxLegs < 2) throw new Error("This broker does not take multi-leg orders here.");
294
+ if (request.contract.secType?.toUpperCase() !== "BAG" || legs.length < 2 || legs.length > maxLegs) throw new Error(`This broker takes spreads of 2 to ${maxLegs} legs.`);
295
+ const identities = new Set<string>();
296
+ for (const leg of legs) {
297
+ const identity = optionContractIdentity(leg.contract);
298
+ if (!identity || leg.contract.brokerId !== context.adapter.id || (leg.contract.brokerInstanceId && leg.contract.brokerInstanceId !== context.instance.id)) throw new Error("Every leg must be an option from this broker profile.");
299
+ if (leg.contract.symbol.trim().toUpperCase() !== request.contract.symbol.trim().toUpperCase()) throw new Error("Every leg must be an option on the spread's underlying.");
300
+ if (!["BUY", "SELL"].includes(leg.action) || !Number.isSafeInteger(leg.ratio) || leg.ratio <= 0) throw new Error("Every leg needs a side and a whole number of contracts.");
301
+ if (identities.has(identity)) throw new Error("A contract can appear in only one leg.");
302
+ identities.add(identity);
303
+ }
304
+ }
305
+
306
+ private quoteRisk(quote: BrokerTicketQuote): string {
307
+ return `${quote.delayed}:${this.quoteIsStale(quote.quote)}`;
308
+ }
309
+
310
+ private quoteIsStale(quote: Quote): boolean {
311
+ return quote.stale === true || !Number.isFinite(quote.lastUpdated) || this.now() - quote.lastUpdated > this.staleQuoteMs;
312
+ }
313
+
314
+ /** The ticket's quote from the broker's answer, checked against the draft it was asked for. */
315
+ private ticketQuote(context: BrokerTradingContext, draft: BrokerOrderRequest, quote: Quote | null, legQuotes?: Quote[]): BrokerTicketQuote {
316
+ const compact = (symbol: string) => symbol.replace(/\s+/g, "").toUpperCase();
317
+ if (legQuotes) {
318
+ if (legQuotes.some((legQuote, index) => compact(legQuote.symbol) !== compact(draft.legs![index]!.contract.localSymbol ?? draft.legs![index]!.contract.symbol))) throw new Error("The broker returned a quote for another contract than a leg.");
319
+ if (!quote) throw new Error("The broker has no bid and ask for every leg of this spread.");
320
+ } else {
321
+ const allowedSymbols = [draft.contract.symbol, draft.contract.localSymbol].filter(Boolean).map((symbol) => compact(symbol!));
322
+ if (!quote || !allowedSymbols.includes(compact(quote.symbol)) || !positive(quote.price)) throw new Error("The broker returned no usable quote for this instrument.");
323
+ }
324
+ const delayed = quote.dataSource !== "live" || context.connection.quoteData !== "realtime";
325
+ return { quote: copy(quote), brokerId: context.adapter.id, brokerInstanceId: context.instance.id,
326
+ sourceLabel: `${context.adapter.name}${delayed ? ", delayed" : ""}`, receivedAt: this.now(), delayed,
327
+ stale: this.quoteIsStale(quote), ...(legQuotes ? { legs: copy(legQuotes) } : {}) };
328
+ }
329
+
330
+ /** The symbol a stream target names for the draft's instrument, or for each of a spread's legs. */
331
+ static streamSymbols(draft: BrokerOrderRequest): string[] {
332
+ return draft.legs?.length ? draft.legs.map((leg) => leg.contract.localSymbol ?? leg.contract.symbol) : [draft.contract.symbol];
333
+ }
334
+
335
+ /**
336
+ * A quote the broker's stream pushed for the draft's instrument or one of
337
+ * its legs. It replaces the ticket's quote once every leg has one, under the
338
+ * same checks as a requested quote.
339
+ */
340
+ applyStreamQuote(symbol: string, quote: Quote): void {
341
+ if (this.disposed) return;
342
+ const draft = this.snapshot.draft;
343
+ if (!draft) return;
344
+ const key = symbol.replace(/\s+/g, "").toUpperCase();
345
+ if (!BrokerTradingController.streamSymbols(draft).some((value) => value.replace(/\s+/g, "").toUpperCase() === key)) return;
346
+ this.streamQuotes.set(key, quote);
347
+ const context = this.options.getContext(draft);
348
+ if (context.connection.state !== "connected") return;
349
+ const strategy = !!draft.legs?.length;
350
+ const legQuotes = strategy ? draft.legs!.map((leg) => this.streamQuotes.get((leg.contract.localSymbol ?? leg.contract.symbol).replace(/\s+/g, "").toUpperCase())) : undefined;
351
+ if (legQuotes?.some((value) => !value)) return;
352
+ try {
353
+ const value = this.ticketQuote(context, draft, legQuotes ? strategyQuote(draft, legQuotes as Quote[]) : quote, legQuotes as Quote[] | undefined);
354
+ this.streamed = { contractKey: canonical([draft.contract, draft.legs ?? null]), quote: value };
355
+ // The stream answered what a quote request could not.
356
+ const answered = this.snapshot.error !== undefined && this.snapshot.error === this.quoteError;
357
+ this.publish({ quote: value, loadingQuote: false, ...(answered ? { error: undefined } : {}) });
358
+ } catch { /* A quote that fails the checks leaves the last good one. */ }
359
+ }
360
+
361
+ /** A streamed quote for the current draft, still being pushed, stands in for asking. */
362
+ private currentStreamQuote(draft: BrokerOrderRequest): BrokerTicketQuote | undefined {
363
+ const streamed = this.streamed;
364
+ if (!streamed || streamed.contractKey !== canonical([draft.contract, draft.legs ?? null])) return undefined;
365
+ // A spread's net quote is made from its legs, so the legs say whether it is still streamed.
366
+ const quotes = streamed.quote.legs ?? [streamed.quote.quote];
367
+ // The stream repeats an unchanged quote at least every minute while it is up.
368
+ return quotes.every((quote) => quote.delivery === "stream") && this.now() - streamed.quote.receivedAt <= STREAM_QUOTE_MAX_SILENCE_MS ? streamed.quote : undefined;
369
+ }
370
+
371
+ async loadQuote(): Promise<BrokerTicketQuote> {
372
+ const { context } = this.context(false);
373
+ const draft = this.snapshot.draft;
374
+ if (!draft || !context.adapter.getQuote) throw new Error("This broker cannot provide a quote for the ticket.");
375
+ // A new quote answers only the last quote's failure; why a review ended, say, stays in view.
376
+ const quoteErrorCleared = this.snapshot.error !== undefined && this.snapshot.error === this.quoteError ? { error: undefined } : {};
377
+ const streamed = this.currentStreamQuote(draft);
378
+ if (streamed) {
379
+ const value = { ...copy(streamed), stale: this.quoteIsStale(streamed.quote) };
380
+ this.publish({ quote: value, loadingQuote: false, ...quoteErrorCleared });
381
+ return copy(value);
382
+ }
383
+ const instrumentKey = (request?: BrokerOrderRequest) => canonical([request?.contract, request?.legs ?? null]);
384
+ const fingerprint = this.key(), contractKey = instrumentKey(draft), version = ++this.quoteVersion;
385
+ this.publish({ loadingQuote: true, ...quoteErrorCleared });
386
+ try {
387
+ const strategy = !!draft.legs?.length;
388
+ // A spread is priced from its legs' quotes at the same broker; it has no quote of its own.
389
+ const legQuotes = strategy ? await Promise.all(draft.legs!.map((leg) => context.adapter.getQuote!(leg.contract.localSymbol ?? leg.contract.symbol, context.instance, leg.contract.exchange, copy(leg.contract)))) : undefined;
390
+ const quote = legQuotes ? strategyQuote(draft, legQuotes) : await context.adapter.getQuote(draft.contract.symbol, context.instance, draft.contract.exchange, copy(draft.contract));
391
+ if (this.disposed || version !== this.quoteVersion || fingerprint !== this.key() || contractKey !== instrumentKey(this.snapshot.draft)) throw new Error("The quote belongs to an earlier ticket selection. Refresh it.");
392
+ // The stream answered while this request ran: its quote is the newer one.
393
+ const pushed = this.currentStreamQuote(draft);
394
+ const result = pushed ? { ...copy(pushed), stale: this.quoteIsStale(pushed.quote) } : this.ticketQuote(context, draft, quote, legQuotes);
395
+ this.publish({ quote: result, loadingQuote: false });
396
+ return copy(result);
397
+ } catch (error) {
398
+ if (version === this.quoteVersion) {
399
+ this.quoteError = errorMessage(error, "The broker quote is unavailable.");
400
+ this.publish({ loadingQuote: false, error: this.quoteError });
401
+ }
402
+ throw error;
403
+ }
404
+ }
405
+
406
+ applyPriceDefault(source: "bid" | "ask" | "mid" | "last", field: "limitPrice" | "stopPrice" = "limitPrice"): void {
407
+ this.editable();
408
+ this.syncContext();
409
+ const draft = this.snapshot.draft, value = this.snapshot.quote;
410
+ if (!draft || !value) throw new Error("Load this broker's quote before choosing a price.");
411
+ const quote = value.quote;
412
+ // A spread's last is its mid: legs do not trade together.
413
+ const price = source === "mid" || (source === "last" && draft.legs?.length) ? positive(quote.bid) && positive(quote.ask) ? (quote.bid + quote.ask) / 2 : undefined : source === "last" ? quote.price : quote[source];
414
+ if (!positive(price)) throw new Error("That broker quote price is unavailable.");
415
+ // To the tick: a sub-penny print is no limit for a stock above a dollar.
416
+ const capabilities = tradingCapabilities(this.options.getContext(draft), draft);
417
+ const step = capabilities.priceStep ?? ticketEditingPriceStep(price, capabilities.priceDecimals);
418
+ const decimals = Math.max(0, Math.ceil(-Math.log10(step) - 1e-9));
419
+ const rounded = Number((Math.round(price / step) * step).toFixed(Math.min(18, decimals)));
420
+ this.setDraft({ ...draft, [field]: positive(rounded) ? rounded : price });
421
+ }
422
+
423
+ setConfirmOrders(confirm: boolean): void {
424
+ this.editable();
425
+ const { account } = this.context(false);
426
+ if (this.entryPending || this.snapshot.phase !== "editing") throw new Error("Wait for the current order review before changing confirmation.");
427
+ if (!confirm && account.tradingMode !== "simulation") throw new Error("Only simulation accounts can skip confirmation.");
428
+ this.publish({ confirmOrders: confirm });
429
+ }
430
+
431
+ /** One deliberate entry action. Even fast SIM orders consume a fresh exact-draft review. */
432
+ async submitFromEntry(): Promise<void> {
433
+ this.editable();
434
+ const { account } = this.context();
435
+ if (this.entryPending || this.snapshot.phase !== "editing") throw new Error("An order action is already pending or needs reconciliation.");
436
+ const fast = !this.snapshot.confirmOrders && account.tradingMode === "simulation" && !this.modifyOrder;
437
+ this.entryPending = true;
438
+ try {
439
+ await this.prepareReview(fast);
440
+ // New risk information cannot be waived by a session preference.
441
+ const review = this.snapshot.review;
442
+ if (fast && review && !review.requiresTypedConfirmation && !review.warnings.length && !review.preview.errors?.length) await this.submitReviewed(undefined, true);
443
+ } finally { this.entryPending = false; }
444
+ }
445
+
446
+ async review(): Promise<void> {
447
+ if (this.entryPending) throw new Error("An order action is already pending.");
448
+ await this.prepareReview();
449
+ }
450
+
451
+ private async prepareReview(skipConfirmation = false): Promise<void> {
452
+ this.editable();
453
+ const request = this.request();
454
+ const { context, account, capabilities } = this.context();
455
+ if (!context.adapter.previewOrder || !context.adapter.placeOrder) throw new Error("This broker does not support order preview and submission.");
456
+ if (this.modifyOrder && (!context.adapter.modifyOrder || !capabilities.modify)) throw new Error("This broker does not support modifying this order.");
457
+ const fingerprint = this.key(), version = ++this.version;
458
+ this.gate = undefined;
459
+ this.publish({ phase: "previewing", review: undefined, error: undefined });
460
+ try {
461
+ const quote = await this.loadQuote();
462
+ if (version !== this.version || fingerprint !== this.key()) throw new Error("The order changed. Review it again.");
463
+ const preview = await context.adapter.previewOrder(context.instance, copy(request));
464
+ if (this.disposed || version !== this.version || fingerprint !== this.key() || canonical(request) !== canonical(this.snapshot.draft)) throw new Error("The order changed. Review it again.");
465
+ quote.stale = this.quoteIsStale(quote.quote);
466
+ const warnings = [...(preview.warnings ?? (preview.warningText ? [preview.warningText] : []))];
467
+ if (quote.delayed) warnings.push("The broker quote is delayed.");
468
+ if (quote.stale) warnings.push("The broker quote is stale. Prices may have changed.");
469
+ if (request.orderType === "MKT" && (quote.delayed || quote.stale)) warnings.push("A market order can fill far from the displayed price.");
470
+ // A buy stop at or under the ask, or a sell stop at or over the bid, is already triggered: it trades now.
471
+ const touch = request.action === "SELL" ? quote.quote.bid : quote.quote.ask;
472
+ if ((request.orderType === "STP" || request.orderType === "STP LMT") && positive(request.stopPrice) && positive(touch)
473
+ && (request.action === "SELL" ? request.stopPrice >= touch : request.stopPrice <= touch)) {
474
+ warnings.push(request.action === "SELL" ? "This sell stop is at or above the bid, so it triggers at once." : "This buy stop is at or below the ask, so it triggers at once.");
475
+ }
476
+ if (quote.quote.marketState === "CLOSED") warnings.push("The broker reports the market is closed. The order may wait for another session.");
477
+ // Legs whose quotes net the other way: a debit order that would be paid, or a credit order that would pay.
478
+ if (request.legs?.length && Number.isFinite(quote.quote.price) && quote.quote.price <= 0) warnings.push(request.action === "BUY"
479
+ ? "At current quotes this spread receives a credit. Choose Sell to be paid for it." : "At current quotes this spread costs a debit. Choose Buy to pay for it.");
480
+ const estimate = preview.estimatedCost ?? (orderEstimatePrice(request, quote.quote) ?? NaN) * request.quantity * Number(request.contract.multiplier ?? "1");
481
+ if (positive(account.buyingPower) && Number.isFinite(estimate) && Math.abs(estimate) > account.buyingPower * 0.25) warnings.push("This order exceeds 25% of the account's buying power.");
482
+ const result: BrokerOrderReview = { request, preview: copy(preview), warnings: [...new Set(warnings)], account: copy(account), brokerName: context.adapter.name,
483
+ createdAt: this.now(), mode: this.modifyOrder ? "modify" : "place", requiresTypedConfirmation: account.tradingMode !== "simulation", confirmationText: request.contract.symbol.toUpperCase() };
484
+ this.gate = { context: fingerprint, request: canonical(request), version, quoteRisk: this.quoteRisk(quote), order: this.modifyOrder ? copy(this.modifyOrder) : undefined };
485
+ this.publish({ phase: skipConfirmation && !result.warnings.length && !preview.errors?.length ? "previewing" : "review", review: result, quote, error: preview.errors?.length ? "The broker preview has blocking errors." : undefined });
486
+ } catch (error) {
487
+ if (!this.disposed && version === this.version) this.publish({ phase: "editing", review: undefined, error: errorMessage(error, "The broker could not preview this order.") });
488
+ throw error;
489
+ }
490
+ }
491
+
492
+ async confirm(typedConfirmation?: string): Promise<void> {
493
+ await this.submitReviewed(typedConfirmation);
494
+ }
495
+
496
+ private async submitReviewed(typedConfirmation?: string, fromEntry = false): Promise<void> {
497
+ this.editable();
498
+ const request = this.request(), review = this.snapshot.review, gate = this.gate;
499
+ const { context } = this.context();
500
+ if (this.snapshot.phase !== (fromEntry ? "previewing" : "review") || fromEntry && (this.snapshot.confirmOrders || review?.requiresTypedConfirmation)
501
+ || !review || !gate || gate.version !== this.version || gate.context !== this.key() || gate.request !== canonical(request)) {
502
+ throw new Error("Review the current order before submitting.");
503
+ }
504
+ if (review.preview.errors?.length) throw new Error("Resolve the broker preview errors before submitting.");
505
+ if (this.now() - review.createdAt > this.reviewTtlMs || !this.snapshot.quote || gate.quoteRisk !== this.quoteRisk(this.snapshot.quote)) {
506
+ this.gate = undefined;
507
+ this.publish({ phase: "editing", review: undefined, error: "The review expired or the quote became stale. Review again." });
508
+ throw new Error("The review expired or the quote became stale. Review again.");
509
+ }
510
+ if (review.requiresTypedConfirmation && typedConfirmation?.trim().toUpperCase() !== review.confirmationText) throw new Error(`Type ${review.confirmationText} to confirm this LIVE order.`);
511
+ this.gate = undefined;
512
+ this.version++;
513
+ this.mutationPending = true;
514
+ this.publish({ phase: "submitting", error: undefined });
515
+ try {
516
+ const result = gate.order ? await context.adapter.modifyOrder!(context.instance, gate.order.orderId, copy(request)) : await context.adapter.placeOrder!(context.instance, copy(request));
517
+ if (!Number.isSafeInteger(result.orderId) || result.orderId <= 0 || (result.accountId && result.accountId !== request.accountId)
518
+ || (result.brokerInstanceId && result.brokerInstanceId !== context.instance.id) || result.contract.brokerId !== context.adapter.id) {
519
+ throw new Error("The broker's order acknowledgement could not be verified.");
520
+ }
521
+ this.publish({ phase: "result", result: copy(result), resultQuantities: "confirmed", review: undefined, receipt: review });
522
+ } catch (error) {
523
+ const reason = refusal(error);
524
+ if (reason && gate.order) await this.settleRefusal(gate.order, reason, "modify");
525
+ // A refused placement placed nothing: the order is the draft again, with the broker's reason.
526
+ else if (reason) this.publish({ phase: "editing", review: undefined, receipt: undefined, error: reason });
527
+ else {
528
+ this.publish({ phase: "result", review: undefined, receipt: review, result: this.unknown(request, gate.order), resultQuantities: gate.order ? "last-known" : "unconfirmed",
529
+ error: gate.order ? uncertainChange : uncertainAfter(error) });
530
+ }
531
+ } finally { this.mutationPending = false; }
532
+ }
533
+
534
+ beginModify(order: BrokerOrder): void {
535
+ this.editable();
536
+ const request = this.orderRequest(order);
537
+ const { context, capabilities } = this.context(true, request);
538
+ this.checkOrder(order, context, "modify");
539
+ if (!capabilities.modify || !context.adapter.modifyOrder) throw new Error("This broker does not support modifying this order.");
540
+ if (!["MKT", "LMT", "STP", "STP LMT"].includes(order.orderType)) throw new Error("This order type cannot be modified in the shared ticket.");
541
+ this.modifyOrder = undefined;
542
+ this.setDraft({ ...request, brokerInstanceId: context.instance.id });
543
+ this.modifyOrder = copy(order);
544
+ this.publish({ modifying: true });
545
+ }
546
+
547
+ /** An order being replaced can still be cancelled: the cancel takes whichever terms the broker holds. */
548
+ /** The working order a modify replaces, as it was when the change began. */
549
+ modifyingOrder(): BrokerOrder | undefined { return this.modifyOrder && copy(this.modifyOrder); }
550
+
551
+ private checkOrder(order: BrokerOrder, context: BrokerTradingContext, action: "modify" | "cancel"): void {
552
+ if (order.accountId !== context.accountId || (order.brokerInstanceId && order.brokerInstanceId !== context.instance.id) || order.contract.brokerId !== context.adapter.id) throw new Error("Select the order's original broker profile and account.");
553
+ const status = order.status.toUpperCase();
554
+ if (TERMINAL.has(status)) throw new Error("This order has ended.");
555
+ if (status === "PENDING_CANCEL") throw new Error("This order is already being cancelled.");
556
+ if (status === "PENDING_REPLACE" && action === "modify") throw new Error("The broker has not taken the last change yet. Wait for it, or cancel the order.");
557
+ // An order the venue has not acknowledged can be cancelled, not replaced: a replacement can lose it (Aries, in simulation).
558
+ if (status === "PENDING_NEW" && action === "modify") throw new Error("The broker has not acknowledged this order yet. Wait for it, or cancel the order.");
559
+ if (status === "UNKNOWN") throw new Error("The broker has not confirmed this order's last change yet.");
560
+ }
561
+
562
+ private orderRequest(order: BrokerOrder): BrokerOrderRequest {
563
+ return { brokerInstanceId: order.brokerInstanceId, accountId: order.accountId, contract: copy(order.contract), action: order.action,
564
+ orderType: order.orderType as BrokerOrderRequest["orderType"], quantity: order.quantity, limitPrice: order.limitPrice, stopPrice: order.stopPrice, tif: order.tif,
565
+ ...(order.legs?.length ? { legs: copy(order.legs) } : {}) };
566
+ }
567
+
568
+ requestCancel(order: BrokerOrder): void {
569
+ this.editable();
570
+ const { context, capabilities } = this.context(true, this.orderRequest(order));
571
+ this.checkOrder(order, context, "cancel");
572
+ if (!capabilities.cancel || !context.adapter.cancelOrder) throw new Error("This broker does not support cancelling this order.");
573
+ this.version++;
574
+ this.gate = { context: this.key(), request: canonical(order), version: this.version, quoteRisk: "" };
575
+ this.publish({ phase: "cancel-review", cancelOrder: copy(order), review: undefined, receipt: undefined, error: undefined });
576
+ }
577
+
578
+ async confirmCancel(confirmed: boolean): Promise<void> {
579
+ this.editable();
580
+ if (!confirmed) { this.reset(); return; }
581
+ const order = this.snapshot.cancelOrder, gate = this.gate;
582
+ const { context, capabilities } = this.context(true, order ? this.orderRequest(order) : undefined);
583
+ if (!capabilities.cancel || !context.adapter.cancelOrder || this.snapshot.phase !== "cancel-review" || !order
584
+ || !gate || gate.context !== this.key() || gate.version !== this.version || gate.request !== canonical(order)) {
585
+ throw new Error("Confirm cancellation of the current order first.");
586
+ }
587
+ this.checkOrder(order, context, "cancel");
588
+ this.gate = undefined;
589
+ this.version++;
590
+ this.mutationPending = true;
591
+ this.publish({ phase: "cancelling", error: undefined });
592
+ try {
593
+ await context.adapter.cancelOrder(context.instance, order.orderId);
594
+ this.publish({ phase: "result", cancelOrder: undefined, resultQuantities: "last-known", result: { ...copy(order), status: "PENDING_CANCEL", updatedAt: this.now() } });
595
+ } catch (error) {
596
+ const reason = refusal(error);
597
+ if (reason) await this.settleRefusal(order, reason, "cancel");
598
+ else this.publish({ phase: "result", cancelOrder: undefined, resultQuantities: "last-known", result: { ...copy(order), status: "UNKNOWN", updatedAt: this.now() }, error: uncertainChange });
599
+ } finally { this.mutationPending = false; }
600
+ }
601
+
602
+ /** The order as the broker reports it now, by its handle; undefined when the broker cannot say. */
603
+ private async lookup(order: BrokerOrder): Promise<BrokerOrder | undefined> {
604
+ const { context } = this.context(false);
605
+ try {
606
+ const orders = context.adapter.getOrderStatus && order.orderId !== 0
607
+ ? [await context.adapter.getOrderStatus(context.instance, order.orderId)]
608
+ : await context.adapter.listOpenOrders?.(context.instance) ?? [];
609
+ return orders.find((value): value is BrokerOrder => !!value && value.accountId === order.accountId
610
+ && value.contract.symbol === order.contract.symbol && (value.orderId === order.orderId || sameOrder(value, order)));
611
+ } catch { return undefined; }
612
+ }
613
+
614
+ /**
615
+ * After the broker refused a change or cancel, which leaves the order as it was, the order as the broker now
616
+ * reports it decides the one line said about it: "Already filled" when it ended first, "Order changed in Aries,
617
+ * refreshed" when it moved on elsewhere, else the broker's reason. A refused change of an order that stands as it
618
+ * was keeps the ticket on the change, with the reason in the form.
619
+ */
620
+ private async settleRefusal(order: BrokerOrder, reason: string, action: "modify" | "cancel"): Promise<void> {
621
+ const brokerName = this.context(false).context.adapter.name;
622
+ const current = await this.lookup(order);
623
+ if (this.disposed) return;
624
+ const now = current ?? order;
625
+ const label = brokerOrderStatus(now.status);
626
+ const ended = brokerOrderDone(now.status);
627
+ const changed = !!current && (current.brokerOrderId !== order.brokerOrderId || label !== brokerOrderStatus(order.status) || current.quantity !== order.quantity
628
+ || current.filled !== order.filled || current.limitPrice !== order.limitPrice || current.stopPrice !== order.stopPrice);
629
+ if (action === "modify" && !ended && !changed) {
630
+ this.publish({ phase: "editing", review: undefined, receipt: undefined, error: reason });
631
+ return;
632
+ }
633
+ this.publish({ phase: "result", cancelOrder: undefined, review: undefined, result: copy(now), resultQuantities: current ? "confirmed" : "last-known", error: undefined });
634
+ const title = ended ? label === "Rejected" ? reason : `Already ${label.toLowerCase()}` : changed ? `Order changed in ${brokerName}, refreshed` : reason;
635
+ this.options.onOutcome?.({ title, type: ended || changed ? "info" : "error", order: copy(now) });
636
+ }
637
+
638
+ private unknown(request: BrokerOrderRequest, original?: BrokerOrder): BrokerOrder {
639
+ return { ...original, orderId: original?.orderId ?? 0, brokerInstanceId: request.brokerInstanceId, accountId: request.accountId, contract: copy(request.contract),
640
+ ...(request.legs?.length ? { legs: copy(request.legs) } : {}),
641
+ status: "UNKNOWN", action: request.action, orderType: request.orderType, quantity: request.quantity, filled: original?.filled ?? 0,
642
+ remaining: original?.remaining ?? request.quantity, limitPrice: request.limitPrice, stopPrice: request.stopPrice, tif: request.tif, updatedAt: this.now() };
643
+ }
644
+
645
+ async refreshResult(): Promise<void> {
646
+ const result = this.snapshot.result;
647
+ if (!result) return;
648
+ const { context } = this.context(false);
649
+ if (!context.adapter.listOpenOrders && !context.adapter.getOrderStatus) throw new Error("Refresh this order in your broker.");
650
+ const fingerprint = this.key();
651
+ const orders = context.adapter.getOrderStatus && result.orderId !== 0
652
+ ? [await context.adapter.getOrderStatus(context.instance, result.orderId)].filter((order): order is BrokerOrder => order !== null)
653
+ : await context.adapter.listOpenOrders?.(context.instance) ?? [];
654
+ if (this.disposed || fingerprint !== this.key() || this.snapshot.result !== result) return;
655
+ const current = orders.find((order) => order.accountId === result.accountId && order.contract.symbol === result.contract.symbol && sameOrder(order, result));
656
+ if (current) this.publish({ result: copy(current), resultQuantities: "confirmed", error: current.status === "UNKNOWN" ? uncertainChange : undefined });
657
+ // Absence from open orders does not prove a fill, rejection or cancellation.
658
+ }
659
+
660
+ /**
661
+ * The result's order as the broker last reported it, from the app's order
662
+ * feed (see useFeedOrderResult). Only the same account and native order (or
663
+ * acknowledged handle) updates it; an order that ended does not go back to
664
+ * working, and an unknown outcome waits for an end or a refresh. Answers the
665
+ * result as it now stands.
666
+ */
667
+ applyOrderUpdate = (order: BrokerOrder): BrokerOrder | undefined => {
668
+ const result = this.snapshot.result;
669
+ if (this.disposed || this.mutationPending || !result || order.accountId !== result.accountId || !sameOrder(order, result)) return result;
670
+ if (order.contract.brokerId !== result.contract.brokerId || (order.brokerInstanceId && result.brokerInstanceId && order.brokerInstanceId !== result.brokerInstanceId)) return result;
671
+ if (TERMINAL.has(result.status.toUpperCase()) && !TERMINAL.has(order.status.toUpperCase())) return result;
672
+ // An action whose outcome is unknown is settled by the order ending, or by refreshing it; a working order may not have seen it yet.
673
+ if (result.status.toUpperCase() === "UNKNOWN" && !TERMINAL.has(order.status.toUpperCase())) return result;
674
+ if (canonical(order) === canonical(result)) return result;
675
+ this.publish({ result: copy(order), resultQuantities: "confirmed", error: order.status === "UNKNOWN" ? uncertainChange : undefined });
676
+ return this.snapshot.result;
677
+ };
678
+
679
+ reset(): void {
680
+ this.editable();
681
+ this.version++;
682
+ this.gate = undefined;
683
+ this.modifyOrder = undefined;
684
+ this.publish({ phase: "editing", modifying: false, review: undefined, receipt: undefined, result: undefined, cancelOrder: undefined, error: undefined });
685
+ }
686
+
687
+ dispose(): void { this.disposed = true; this.version++; this.quoteVersion++; this.gate = undefined; this.listeners.clear(); this.streamQuotes.clear(); }
688
+ }