Skip to main content

nautilus_betfair/http/
models.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Betfair REST/JSON-RPC API model types.
17//!
18//! These types cover the Betting API, Accounts API, Identity API, and
19//! Navigation API. All use camelCase JSON field naming.
20//!
21//! # References
22//!
23//! <https://docs.developer.betfair.com/>
24
25use std::fmt::Debug;
26
27use ahash::AHashMap;
28use nautilus_core::{
29    serialization::{
30        deserialize_decimal, deserialize_decimal_native, deserialize_optional_decimal,
31    },
32    string::secret::SecretString,
33};
34use rust_decimal::Decimal;
35use serde::{Deserialize, Serialize};
36use ustr::Ustr;
37use zeroize::ZeroizeOnDrop;
38
39use crate::common::{
40    enums::{
41        BetDelayModel, BetStatus, BetTargetType, BetfairOrderStatus, BetfairOrderType, BetfairSide,
42        BetfairTimeInForce, CertLoginStatus, ExecutionReportErrorCode, ExecutionReportStatus,
43        GroupBy, InstructionReportErrorCode, InstructionReportStatus, MarketBettingType,
44        MarketProjection, MarketSort, OrderBy, OrderProjection, PersistenceType, PriceLadderType,
45        SortDir,
46    },
47    types::{
48        BetId, CompetitionId, CustomerOrderRef, CustomerStrategyRef, EventId, EventTypeId,
49        Handicap, MarketId, SelectionId, deserialize_optional_decimal_native,
50        deserialize_optional_string_lenient, deserialize_optional_u32_lenient,
51    },
52};
53
54/// Login status.
55#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
56#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
57pub enum LoginStatus {
58    Success,
59    LimitedAccess,
60    LoginRestricted,
61    Fail,
62}
63
64/// Login response from the interactive Identity SSO API.
65#[derive(Debug, Clone, Deserialize, ZeroizeOnDrop)]
66pub struct LoginResponse {
67    pub token: SecretString,
68    pub product: String,
69    #[zeroize(skip)]
70    pub status: LoginStatus,
71    pub error: Option<String>,
72}
73
74/// Login response from the certificate-based SSO API (`certlogin`).
75///
76/// Uses different field names from the interactive login endpoint.
77#[derive(Debug, Clone, Deserialize, ZeroizeOnDrop)]
78#[serde(rename_all = "camelCase")]
79pub struct CertLoginResponse {
80    pub session_token: Option<SecretString>,
81    #[zeroize(skip)]
82    pub login_status: CertLoginStatus,
83}
84
85/// Account details response.
86#[derive(Debug, Clone, Deserialize)]
87#[serde(rename_all = "camelCase")]
88pub struct AccountDetailsResponse {
89    pub currency_code: Option<Ustr>,
90    pub first_name: Option<String>,
91    pub last_name: Option<String>,
92    pub locale_code: Option<Ustr>,
93    pub region: Option<Ustr>,
94    pub timezone: Option<Ustr>,
95    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
96    pub discount_rate: Option<Decimal>,
97    pub points_balance: Option<i64>,
98    pub country_code: Option<Ustr>,
99}
100
101/// Account funds response.
102#[derive(Debug, Clone, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct AccountFundsResponse {
105    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
106    pub available_to_bet_balance: Option<Decimal>,
107    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
108    pub exposure: Option<Decimal>,
109    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
110    pub retained_commission: Option<Decimal>,
111    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
112    pub exposure_limit: Option<Decimal>,
113    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
114    pub discount_rate: Option<Decimal>,
115    pub points_balance: Option<i64>,
116    pub wallet: Option<Ustr>,
117}
118
119/// Time range filter.
120#[derive(Debug, Clone, Default, Serialize, Deserialize)]
121pub struct TimeRange {
122    pub from: Option<String>,
123    pub to: Option<String>,
124}
125
126/// Price-size pair.
127#[derive(Debug, Clone, Deserialize)]
128pub struct PriceSize {
129    #[serde(deserialize_with = "deserialize_decimal")]
130    pub price: Decimal,
131    #[serde(deserialize_with = "deserialize_decimal")]
132    pub size: Decimal,
133}
134
135/// Market version for price protection.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct MarketVersion {
138    pub version: Option<i64>,
139}
140
141/// Event type (e.g. "Soccer", "Horse Racing").
142#[derive(Debug, Clone, Deserialize)]
143pub struct EventType {
144    #[serde(default, deserialize_with = "deserialize_optional_string_lenient")]
145    pub id: Option<EventTypeId>,
146    pub name: Option<Ustr>,
147}
148
149/// Event (e.g. a specific football match).
150#[derive(Debug, Clone, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct Event {
153    #[serde(default, deserialize_with = "deserialize_optional_string_lenient")]
154    pub id: Option<EventId>,
155    pub name: Option<String>,
156    pub country_code: Option<Ustr>,
157    pub timezone: Option<Ustr>,
158    pub venue: Option<Ustr>,
159    pub open_date: Option<String>,
160}
161
162/// Competition (e.g. "English Premier League").
163#[derive(Debug, Clone, Deserialize)]
164pub struct Competition {
165    #[serde(default, deserialize_with = "deserialize_optional_string_lenient")]
166    pub id: Option<CompetitionId>,
167    pub name: Option<String>,
168}
169
170/// Runner identifier.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct RunnerId {
174    pub market_id: MarketId,
175    pub selection_id: SelectionId,
176    #[serde(default, deserialize_with = "deserialize_optional_decimal_native")]
177    pub handicap: Option<Handicap>,
178}
179
180/// Market catalog entry returned by `listMarketCatalogue`.
181#[derive(Debug, Clone, Deserialize)]
182#[serde(rename_all = "camelCase")]
183pub struct MarketCatalogue {
184    pub market_id: MarketId,
185    pub market_name: String,
186    pub market_start_time: Option<String>,
187    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
188    pub total_matched: Option<Decimal>,
189    pub event_type: Option<EventType>,
190    pub competition: Option<Competition>,
191    pub description: Option<MarketDescription>,
192    pub event: Option<Event>,
193    pub runners: Option<Vec<RunnerCatalog>>,
194}
195
196/// Detailed market description.
197#[derive(Debug, Clone, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub struct MarketDescription {
200    pub betting_type: MarketBettingType,
201    pub bsp_market: bool,
202    pub clarifications: Option<String>,
203    pub discount_allowed: bool,
204    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
205    pub each_way_divisor: Option<Decimal>,
206    #[serde(deserialize_with = "deserialize_decimal")]
207    pub market_base_rate: Decimal,
208    pub market_time: String,
209    pub market_type: Ustr,
210    pub persistence_enabled: bool,
211    pub race_type: Option<Ustr>,
212    pub regulator: Ustr,
213    pub rules: Option<String>,
214    pub rules_has_date: Option<bool>,
215    pub settle_time: Option<String>,
216    pub suspend_time: String,
217    pub turn_in_play_enabled: bool,
218    pub wallet: Option<Ustr>,
219    pub bet_delay_models: Option<Vec<BetDelayModel>>,
220    pub line_range_info: Option<LineRangeInfo>,
221    pub price_ladder_description: Option<PriceLadderDescription>,
222}
223
224/// Price ladder description within a market.
225#[derive(Debug, Clone, Deserialize)]
226pub struct PriceLadderDescription {
227    #[serde(rename = "type")]
228    pub ladder_type: Option<PriceLadderType>,
229}
230
231/// Line range info for line markets.
232#[derive(Debug, Clone, Deserialize)]
233#[serde(rename_all = "camelCase")]
234pub struct LineRangeInfo {
235    #[serde(deserialize_with = "deserialize_decimal")]
236    pub max_unit_value: Decimal,
237    #[serde(deserialize_with = "deserialize_decimal")]
238    pub min_unit_value: Decimal,
239    #[serde(deserialize_with = "deserialize_decimal")]
240    pub interval: Decimal,
241}
242
243/// Runner catalog entry (static runner information).
244#[derive(Debug, Clone, Deserialize)]
245#[serde(rename_all = "camelCase")]
246pub struct RunnerCatalog {
247    pub selection_id: SelectionId,
248    pub runner_name: String,
249    #[serde(deserialize_with = "deserialize_decimal_native")]
250    pub handicap: Handicap,
251    pub sort_priority: Option<u32>,
252    /// Free-form metadata keyed by SCREAMING_SNAKE_CASE field names.
253    ///
254    /// The Betfair API defines this as `Map<String, String>`, but in practice
255    /// values may be JSON numbers (e.g. AGE, CLOTH_NUMBER, STALL_DRAW). Keys
256    /// and available fields vary by sport and market type.
257    pub metadata: Option<AHashMap<String, serde_json::Value>>,
258}
259
260/// Market filter for REST API queries (e.g. `listMarketCatalogue`).
261#[derive(Debug, Clone, Default, Serialize, Deserialize)]
262#[serde(rename_all = "camelCase")]
263pub struct MarketFilter {
264    #[serde(skip_serializing_if = "Option::is_none")]
265    pub bsp_only: Option<bool>,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub competition_ids: Option<Vec<CompetitionId>>,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub event_ids: Option<Vec<EventId>>,
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub event_type_ids: Option<Vec<EventTypeId>>,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub in_play_only: Option<bool>,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub market_betting_types: Option<Vec<MarketBettingType>>,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub market_countries: Option<Vec<Ustr>>,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub market_ids: Option<Vec<MarketId>>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub market_start_time: Option<TimeRange>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub market_type_codes: Option<Vec<Ustr>>,
284    #[serde(skip_serializing_if = "Option::is_none")]
285    pub race_types: Option<Vec<Ustr>>,
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub text_query: Option<String>,
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub turn_in_play_enabled: Option<bool>,
290    #[serde(skip_serializing_if = "Option::is_none")]
291    pub venues: Option<Vec<Ustr>>,
292    #[serde(skip_serializing_if = "Option::is_none")]
293    pub with_orders: Option<Vec<String>>,
294}
295
296/// Limit order parameters.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct LimitOrder {
300    #[serde(deserialize_with = "deserialize_decimal_native")]
301    pub size: Decimal,
302    #[serde(deserialize_with = "deserialize_decimal_native")]
303    pub price: Decimal,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub persistence_type: Option<PersistenceType>,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub time_in_force: Option<BetfairTimeInForce>,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    #[serde(default, deserialize_with = "deserialize_optional_decimal_native")]
310    pub min_fill_size: Option<Decimal>,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub bet_target_type: Option<BetTargetType>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    #[serde(default, deserialize_with = "deserialize_optional_decimal_native")]
315    pub bet_target_size: Option<Decimal>,
316}
317
318/// Limit-on-close order parameters (for BSP markets).
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct LimitOnCloseOrder {
321    #[serde(deserialize_with = "deserialize_decimal_native")]
322    pub liability: Decimal,
323    #[serde(deserialize_with = "deserialize_decimal_native")]
324    pub price: Decimal,
325}
326
327/// Market-on-close order parameters (for BSP markets).
328#[derive(Debug, Clone, Serialize, Deserialize)]
329pub struct MarketOnCloseOrder {
330    #[serde(deserialize_with = "deserialize_decimal_native")]
331    pub liability: Decimal,
332}
333
334/// Instruction to place a new order.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336#[serde(rename_all = "camelCase")]
337pub struct PlaceInstruction {
338    pub order_type: BetfairOrderType,
339    pub selection_id: SelectionId,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    #[serde(default, deserialize_with = "deserialize_optional_decimal_native")]
342    pub handicap: Option<Handicap>,
343    pub side: BetfairSide,
344    #[serde(skip_serializing_if = "Option::is_none")]
345    pub limit_order: Option<LimitOrder>,
346    #[serde(skip_serializing_if = "Option::is_none")]
347    pub limit_on_close_order: Option<LimitOnCloseOrder>,
348    #[serde(skip_serializing_if = "Option::is_none")]
349    pub market_on_close_order: Option<MarketOnCloseOrder>,
350    #[serde(skip_serializing_if = "Option::is_none")]
351    pub customer_order_ref: Option<String>,
352}
353
354/// Instruction to cancel an existing order.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub struct CancelInstruction {
358    pub bet_id: BetId,
359    #[serde(skip_serializing_if = "Option::is_none")]
360    #[serde(default, deserialize_with = "deserialize_optional_decimal_native")]
361    pub size_reduction: Option<Decimal>,
362}
363
364/// Instruction to replace an existing order (cancel + place at new price).
365#[derive(Debug, Clone, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct ReplaceInstruction {
368    pub bet_id: BetId,
369    #[serde(deserialize_with = "deserialize_decimal_native")]
370    pub new_price: Decimal,
371}
372
373/// Parameters for a `placeOrders` request.
374#[derive(Debug, Clone, Serialize)]
375#[serde(rename_all = "camelCase")]
376pub struct PlaceOrdersParams {
377    pub market_id: MarketId,
378    pub instructions: Vec<PlaceInstruction>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub customer_ref: Option<String>,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub market_version: Option<MarketVersion>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub customer_strategy_ref: Option<CustomerStrategyRef>,
385}
386
387/// Parameters for a `cancelOrders` request.
388#[derive(Debug, Clone, Serialize)]
389#[serde(rename_all = "camelCase")]
390pub struct CancelOrdersParams {
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub market_id: Option<MarketId>,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub instructions: Option<Vec<CancelInstruction>>,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub customer_ref: Option<String>,
397}
398
399/// Parameters for a `replaceOrders` request.
400#[derive(Debug, Clone, Serialize)]
401#[serde(rename_all = "camelCase")]
402pub struct ReplaceOrdersParams {
403    pub market_id: MarketId,
404    pub instructions: Vec<ReplaceInstruction>,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub customer_ref: Option<String>,
407    #[serde(skip_serializing_if = "Option::is_none")]
408    pub market_version: Option<MarketVersion>,
409}
410
411/// Parameters for a `listMarketCatalogue` request.
412#[derive(Debug, Clone, Serialize)]
413#[serde(rename_all = "camelCase")]
414pub struct ListMarketCatalogueParams {
415    pub filter: MarketFilter,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub market_projection: Option<Vec<MarketProjection>>,
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub sort: Option<MarketSort>,
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub max_results: Option<u32>,
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub locale: Option<String>,
424}
425
426/// Parameters for a `listCurrentOrders` request.
427#[derive(Debug, Clone, Serialize)]
428#[serde(rename_all = "camelCase")]
429pub struct ListCurrentOrdersParams {
430    #[serde(skip_serializing_if = "Option::is_none")]
431    pub bet_ids: Option<Vec<BetId>>,
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub market_ids: Option<Vec<MarketId>>,
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub order_projection: Option<OrderProjection>,
436    #[serde(skip_serializing_if = "Option::is_none")]
437    pub customer_order_refs: Option<Vec<CustomerOrderRef>>,
438    #[serde(skip_serializing_if = "Option::is_none")]
439    pub customer_strategy_refs: Option<Vec<CustomerStrategyRef>>,
440    #[serde(skip_serializing_if = "Option::is_none")]
441    pub date_range: Option<TimeRange>,
442    #[serde(skip_serializing_if = "Option::is_none")]
443    pub order_by: Option<OrderBy>,
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub sort_dir: Option<SortDir>,
446    #[serde(skip_serializing_if = "Option::is_none")]
447    pub from_record: Option<u32>,
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub record_count: Option<u32>,
450}
451
452/// Parameters for a `listClearedOrders` request.
453#[derive(Debug, Clone, Serialize)]
454#[serde(rename_all = "camelCase")]
455pub struct ListClearedOrdersParams {
456    pub bet_status: BetStatus,
457    #[serde(skip_serializing_if = "Option::is_none")]
458    pub event_type_ids: Option<Vec<EventTypeId>>,
459    #[serde(skip_serializing_if = "Option::is_none")]
460    pub event_ids: Option<Vec<EventId>>,
461    #[serde(skip_serializing_if = "Option::is_none")]
462    pub market_ids: Option<Vec<MarketId>>,
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub runner_ids: Option<Vec<RunnerId>>,
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub bet_ids: Option<Vec<BetId>>,
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub customer_order_refs: Option<Vec<CustomerOrderRef>>,
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub customer_strategy_refs: Option<Vec<CustomerStrategyRef>>,
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub side: Option<BetfairSide>,
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub settled_date_range: Option<TimeRange>,
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub group_by: Option<GroupBy>,
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub include_item_description: Option<bool>,
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub locale: Option<String>,
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub from_record: Option<u32>,
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub record_count: Option<u32>,
485}
486
487/// Response to a `placeOrders` request.
488#[derive(Debug, Clone, Deserialize)]
489#[serde(rename_all = "camelCase")]
490pub struct PlaceExecutionReport {
491    pub customer_ref: Option<String>,
492    pub status: ExecutionReportStatus,
493    pub error_code: Option<ExecutionReportErrorCode>,
494    pub error_message: Option<String>,
495    pub market_id: Option<MarketId>,
496    pub instruction_reports: Option<Vec<PlaceInstructionReport>>,
497}
498
499/// Individual instruction report for a place operation.
500#[derive(Debug, Clone, Deserialize)]
501#[serde(rename_all = "camelCase")]
502pub struct PlaceInstructionReport {
503    pub status: InstructionReportStatus,
504    pub error_code: Option<InstructionReportErrorCode>,
505    pub error_message: Option<String>,
506    pub order_status: Option<BetfairOrderStatus>,
507    pub instruction: Option<PlaceInstruction>,
508    pub bet_id: Option<BetId>,
509    pub placed_date: Option<String>,
510    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
511    pub average_price_matched: Option<Decimal>,
512    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
513    pub size_matched: Option<Decimal>,
514}
515
516/// Response to a `cancelOrders` request.
517#[derive(Debug, Clone, Deserialize)]
518#[serde(rename_all = "camelCase")]
519pub struct CancelExecutionReport {
520    pub customer_ref: Option<String>,
521    pub status: ExecutionReportStatus,
522    pub error_code: Option<ExecutionReportErrorCode>,
523    pub error_message: Option<String>,
524    pub market_id: Option<MarketId>,
525    pub instruction_reports: Option<Vec<CancelInstructionReport>>,
526}
527
528/// Individual instruction report for a cancel operation.
529#[derive(Debug, Clone, Deserialize)]
530#[serde(rename_all = "camelCase")]
531pub struct CancelInstructionReport {
532    pub status: InstructionReportStatus,
533    pub error_code: Option<InstructionReportErrorCode>,
534    pub error_message: Option<String>,
535    pub instruction: Option<CancelInstruction>,
536    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
537    pub size_cancelled: Option<Decimal>,
538    pub cancelled_date: Option<String>,
539}
540
541/// Response to a `replaceOrders` request.
542#[derive(Debug, Clone, Deserialize)]
543#[serde(rename_all = "camelCase")]
544pub struct ReplaceExecutionReport {
545    pub customer_ref: Option<String>,
546    pub status: ExecutionReportStatus,
547    pub error_code: Option<ExecutionReportErrorCode>,
548    pub error_message: Option<String>,
549    pub market_id: Option<MarketId>,
550    pub instruction_reports: Option<Vec<ReplaceInstructionReport>>,
551}
552
553/// Individual instruction report for a replace operation.
554#[derive(Debug, Clone, Deserialize)]
555#[serde(rename_all = "camelCase")]
556pub struct ReplaceInstructionReport {
557    pub status: InstructionReportStatus,
558    pub error_code: Option<InstructionReportErrorCode>,
559    pub error_message: Option<String>,
560    pub cancel_instruction_report: Option<CancelInstructionReport>,
561    pub place_instruction_report: Option<PlaceInstructionReport>,
562}
563
564/// Current (active) order summary.
565#[derive(Debug, Clone, Deserialize)]
566#[serde(rename_all = "camelCase")]
567pub struct CurrentOrderSummary {
568    pub bet_id: BetId,
569    pub market_id: MarketId,
570    pub selection_id: SelectionId,
571    #[serde(deserialize_with = "deserialize_decimal_native")]
572    pub handicap: Handicap,
573    pub price_size: PriceSize,
574    #[serde(deserialize_with = "deserialize_decimal")]
575    pub bsp_liability: Decimal,
576    pub side: BetfairSide,
577    pub status: BetfairOrderStatus,
578    pub persistence_type: PersistenceType,
579    pub order_type: BetfairOrderType,
580    pub placed_date: String,
581    pub matched_date: Option<String>,
582    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
583    pub average_price_matched: Option<Decimal>,
584    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
585    pub size_matched: Option<Decimal>,
586    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
587    pub size_remaining: Option<Decimal>,
588    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
589    pub size_lapsed: Option<Decimal>,
590    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
591    pub size_cancelled: Option<Decimal>,
592    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
593    pub size_voided: Option<Decimal>,
594    pub regulator_auth_code: Option<String>,
595    pub regulator_code: Option<String>,
596    pub customer_order_ref: Option<CustomerOrderRef>,
597    pub customer_strategy_ref: Option<CustomerStrategyRef>,
598}
599
600/// Report containing current order summaries.
601#[derive(Debug, Clone, Deserialize)]
602#[serde(rename_all = "camelCase")]
603pub struct CurrentOrderSummaryReport {
604    pub current_orders: Vec<CurrentOrderSummary>,
605    pub more_available: bool,
606}
607
608/// Item description for cleared orders (present when `includeItemDescription=true`).
609#[derive(Debug, Clone, Deserialize)]
610#[serde(rename_all = "camelCase")]
611pub struct ItemDescription {
612    pub event_type_desc: Option<String>,
613    pub event_desc: Option<String>,
614    pub market_desc: Option<String>,
615    pub market_type: Option<Ustr>,
616    pub market_start_time: Option<String>,
617    pub runner_desc: Option<String>,
618    pub number_of_winners: Option<u32>,
619    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
620    pub each_way_divisor: Option<Decimal>,
621}
622
623/// Cleared (settled/voided/lapsed/cancelled) order summary.
624#[derive(Debug, Clone, Deserialize)]
625#[serde(rename_all = "camelCase")]
626pub struct ClearedOrderSummary {
627    pub event_type_id: Option<EventTypeId>,
628    pub event_id: Option<EventId>,
629    pub market_id: Option<MarketId>,
630    pub selection_id: Option<SelectionId>,
631    #[serde(default, deserialize_with = "deserialize_optional_decimal_native")]
632    pub handicap: Option<Handicap>,
633    pub bet_id: Option<BetId>,
634    pub placed_date: Option<String>,
635    pub persistence_type: Option<PersistenceType>,
636    pub order_type: Option<BetfairOrderType>,
637    pub side: Option<BetfairSide>,
638    pub item_description: Option<ItemDescription>,
639    pub bet_outcome: Option<String>,
640    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
641    pub price_requested: Option<Decimal>,
642    pub settled_date: Option<String>,
643    pub last_matched_date: Option<String>,
644    pub bet_count: Option<u32>,
645    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
646    pub commission: Option<Decimal>,
647    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
648    pub price_matched: Option<Decimal>,
649    pub price_reduced: Option<bool>,
650    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
651    pub size_settled: Option<Decimal>,
652    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
653    pub profit: Option<Decimal>,
654    #[serde(default, deserialize_with = "deserialize_optional_decimal")]
655    pub size_cancelled: Option<Decimal>,
656    pub customer_order_ref: Option<CustomerOrderRef>,
657    pub customer_strategy_ref: Option<CustomerStrategyRef>,
658}
659
660/// Report containing cleared order summaries.
661#[derive(Debug, Clone, Deserialize)]
662#[serde(rename_all = "camelCase")]
663pub struct ClearedOrderSummaryReport {
664    pub cleared_orders: Vec<ClearedOrderSummary>,
665    pub more_available: bool,
666}
667
668/// Market entry in the navigation tree.
669#[derive(Debug, Clone, Deserialize)]
670#[serde(rename_all = "camelCase")]
671pub struct NavigationMarket {
672    pub name: Option<String>,
673    pub id: Option<MarketId>,
674    pub exchange_id: Option<String>,
675    pub market_type: Option<Ustr>,
676    pub market_start_time: Option<String>,
677    #[serde(default, deserialize_with = "deserialize_optional_u32_lenient")]
678    pub number_of_winners: Option<u32>,
679}
680
681/// Race entry in the navigation tree.
682#[derive(Debug, Clone, Deserialize)]
683#[serde(rename_all = "camelCase")]
684pub struct NavigationRace {
685    pub name: Option<String>,
686    pub id: Option<String>,
687    pub venue: Option<Ustr>,
688    pub start_time: Option<String>,
689    pub race_number: Option<String>,
690    pub country_code: Option<Ustr>,
691    pub children: Option<Vec<NavigationChild>>,
692}
693
694/// Event entry in the navigation tree.
695#[derive(Debug, Clone, Deserialize)]
696#[serde(rename_all = "camelCase")]
697pub struct NavigationEvent {
698    pub name: Option<String>,
699    pub id: Option<EventId>,
700    pub country_code: Option<Ustr>,
701    pub children: Option<Vec<NavigationChild>>,
702}
703
704/// Group entry in the navigation tree.
705#[derive(Debug, Clone, Deserialize)]
706#[serde(rename_all = "camelCase")]
707pub struct NavigationGroup {
708    pub name: Option<String>,
709    pub id: Option<String>,
710    pub children: Option<Vec<NavigationChild>>,
711}
712
713/// Event type (top-level category) in the navigation tree.
714#[derive(Debug, Clone, Deserialize)]
715#[serde(rename_all = "camelCase")]
716pub struct NavigationEventType {
717    pub name: Option<Ustr>,
718    pub id: Option<EventTypeId>,
719    pub children: Option<Vec<NavigationChild>>,
720}
721
722/// Child node in the navigation tree (polymorphic).
723#[derive(Debug, Clone, Deserialize)]
724#[serde(tag = "type")]
725pub enum NavigationChild {
726    #[serde(rename = "EVENT_TYPE")]
727    EventType(NavigationEventType),
728    #[serde(rename = "GROUP")]
729    Group(NavigationGroup),
730    #[serde(rename = "EVENT")]
731    Event(NavigationEvent),
732    #[serde(rename = "RACE")]
733    Race(NavigationRace),
734    #[serde(rename = "MARKET")]
735    Market(NavigationMarket),
736}
737
738/// Root navigation response.
739#[derive(Debug, Clone, Deserialize)]
740pub struct Navigation {
741    pub children: Option<Vec<NavigationChild>>,
742}
743
744/// Flattened (denormalized) view of a market from the navigation tree.
745#[derive(Debug, Clone)]
746pub struct FlattenedMarket {
747    pub event_type_id: Option<String>,
748    pub event_type_name: Option<Ustr>,
749    pub event_id: Option<String>,
750    pub event_name: Option<String>,
751    pub event_country_code: Option<Ustr>,
752    pub market_id: Option<MarketId>,
753    pub market_name: Option<String>,
754    pub market_type: Option<Ustr>,
755    pub market_start_time: Option<String>,
756    pub number_of_winners: Option<u32>,
757}
758
759#[cfg(test)]
760mod tests {
761    use nautilus_core::string::secret::REDACTED;
762    use rstest::rstest;
763
764    use super::*;
765    use crate::common::testing::{load_test_json, parse_jsonrpc};
766
767    #[rstest]
768    #[case("0.1234567890123456789012345678", "0.1234567890123456789012345678")]
769    #[case("0.12345678901234567890123456789", "0.1234567890123456789012345679")]
770    fn test_limit_order_decimal_routes(#[case] size: &str, #[case] expected: &str) {
771        let value = serde_json::json!({
772            "size": size,
773            "price": 9007199254740993u64,
774            "minFillSize": "2.375",
775            "betTargetSize": null
776        });
777        let direct: LimitOrder = serde_json::from_str(&value.to_string()).unwrap();
778        let buffered: LimitOrder = serde_json::from_value(value).unwrap();
779        for order in [direct, buffered] {
780            assert_eq!(order.size, Decimal::from_str_exact(expected).unwrap());
781            assert_eq!(order.price, Decimal::from(9_007_199_254_740_993u64));
782            assert_eq!(order.min_fill_size, Some(Decimal::new(2375, 3)));
783            assert_eq!(order.bet_target_size, None);
784            assert_eq!(order.persistence_type, None);
785            assert_eq!(order.time_in_force, None);
786            assert_eq!(order.bet_target_type, None);
787        }
788
789        for invalid in [
790            serde_json::Value::Null,
791            serde_json::json!(""),
792            serde_json::json!(true),
793        ] {
794            assert!(
795                serde_json::from_value::<LimitOrder>(
796                    serde_json::json!({"size": invalid, "price": 2})
797                )
798                .is_err()
799            );
800        }
801
802        assert!(serde_json::from_value::<LimitOrder>(serde_json::json!({"price": 2})).is_err());
803    }
804
805    fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
806
807    #[rstest]
808    fn test_cert_login_response() {
809        let data = load_test_json("rest/cert_login.json");
810        let resp: CertLoginResponse = serde_json::from_str(&data).unwrap();
811        assert_eq!(resp.login_status, CertLoginStatus::Success);
812        assert!(resp.session_token.is_some());
813    }
814
815    #[rstest]
816    fn test_cert_login_error_response() {
817        let json = r#"{"loginStatus":"CERT_AUTH_REQUIRED"}"#;
818        let resp: CertLoginResponse = serde_json::from_str(json).unwrap();
819        assert_eq!(resp.login_status, CertLoginStatus::CertAuthRequired);
820        assert!(resp.session_token.is_none());
821    }
822
823    #[rstest]
824    fn test_cert_login_unknown_status_deserializes_to_other() {
825        let json = r#"{"loginStatus":"SOME_FUTURE_CODE"}"#;
826        let resp: CertLoginResponse = serde_json::from_str(json).unwrap();
827        assert_eq!(resp.login_status, CertLoginStatus::Other);
828    }
829
830    #[rstest]
831    fn test_interactive_login_response() {
832        let data = load_test_json("rest/login_success.json");
833        let resp: LoginResponse = serde_json::from_str(&data).unwrap();
834        assert_eq!(resp.status, LoginStatus::Success);
835    }
836
837    #[rstest]
838    fn test_login_responses_redact_tokens() {
839        assert_zeroize_on_drop::<LoginResponse>();
840        assert_zeroize_on_drop::<CertLoginResponse>();
841
842        let login = LoginResponse {
843            token: SecretString::from("interactive-session-token"),
844            product: "product".to_string(),
845            status: LoginStatus::Success,
846            error: None,
847        };
848        let certificate = CertLoginResponse {
849            session_token: Some(SecretString::from("certificate-session-token")),
850            login_status: CertLoginStatus::Success,
851        };
852
853        let formatted = format!("{login:?} {certificate:?}");
854
855        assert_eq!(formatted.matches(REDACTED).count(), 2);
856        assert!(!formatted.contains("interactive-session-token"));
857        assert!(!formatted.contains("certificate-session-token"));
858    }
859
860    #[rstest]
861    fn test_interactive_login_failure() {
862        let data = load_test_json("rest/login_failure.json");
863        let resp: LoginResponse = serde_json::from_str(&data).unwrap();
864        assert_eq!(resp.status, LoginStatus::Fail);
865    }
866
867    #[rstest]
868    fn test_list_market_catalogue_with_runner_metadata() {
869        let data = load_test_json("rest/list_market_catalogue.json");
870        let catalogue: MarketCatalogue = serde_json::from_str(&data).unwrap();
871        let runners = catalogue.runners.expect("runners present");
872        let meta = runners[0].metadata.as_ref().expect("metadata present");
873        assert!(meta.contains_key("AGE"));
874        assert!(meta.contains_key("CLOTH_NUMBER"));
875        assert!(meta.contains_key("STALL_DRAW"));
876    }
877
878    #[rstest]
879    fn test_navigation_with_empty_number_of_winners() {
880        let data = load_test_json("rest/navigation_list_navigation.json");
881        let nav: Navigation = serde_json::from_str(&data).unwrap();
882        assert!(nav.children.is_some());
883    }
884
885    fn find_race_with_children(children: &[NavigationChild]) -> bool {
886        for child in children {
887            match child {
888                NavigationChild::Race(race) => {
889                    if let Some(kids) = &race.children
890                        && !kids.is_empty()
891                    {
892                        return true;
893                    }
894                }
895                NavigationChild::EventType(et) => {
896                    if let Some(kids) = &et.children
897                        && find_race_with_children(kids)
898                    {
899                        return true;
900                    }
901                }
902                NavigationChild::Group(g) => {
903                    if let Some(kids) = &g.children
904                        && find_race_with_children(kids)
905                    {
906                        return true;
907                    }
908                }
909                NavigationChild::Event(e) => {
910                    if let Some(kids) = &e.children
911                        && find_race_with_children(kids)
912                    {
913                        return true;
914                    }
915                }
916                NavigationChild::Market(_) => {}
917            }
918        }
919        false
920    }
921
922    #[rstest]
923    fn test_navigation_race_has_children() {
924        let data = load_test_json("rest/navigation_list_navigation.json");
925        let nav: Navigation = serde_json::from_str(&data).unwrap();
926        let children = nav.children.as_ref().unwrap();
927        assert!(
928            find_race_with_children(children),
929            "should find at least one RACE node with MARKET children"
930        );
931    }
932
933    #[rstest]
934    fn test_account_details() {
935        let data = load_test_json("rest/account_details.json");
936        let _resp: AccountDetailsResponse = serde_json::from_str(&data).unwrap();
937    }
938
939    #[rstest]
940    #[case("rest/account_funds_no_exposure.json")]
941    #[case("rest/account_funds_with_exposure.json")]
942    fn test_account_funds(#[case] fixture: &str) {
943        let data = load_test_json(fixture);
944        let _resp: AccountFundsResponse =
945            serde_json::from_str(&data).unwrap_or_else(|e| panic!("{fixture}: {e}"));
946    }
947
948    #[rstest]
949    #[case("rest/betting_place_order_success.json")]
950    #[case("rest/betting_place_order_error.json")]
951    #[case("rest/betting_place_order_batch_success.json")]
952    #[case("rest/betting_place_order_batch_partial_failure.json")]
953    fn test_place_order_responses(#[case] fixture: &str) {
954        let data = load_test_json(fixture);
955        let _resp: PlaceExecutionReport = parse_jsonrpc(&data);
956    }
957
958    #[rstest]
959    #[case("rest/betting_cancel_orders_success.json")]
960    #[case("rest/betting_cancel_orders_error.json")]
961    #[case("rest/betting_cancel_orders_batch_success.json")]
962    #[case("rest/betting_cancel_orders_batch_partial_failure.json")]
963    fn test_cancel_order_responses(#[case] fixture: &str) {
964        let data = load_test_json(fixture);
965        let _resp: CancelExecutionReport = parse_jsonrpc(&data);
966    }
967
968    #[rstest]
969    #[case("rest/betting_replace_orders_success.json")]
970    #[case("rest/betting_replace_orders_cancelled_not_placed_live.json")]
971    fn test_replace_order_responses(#[case] fixture: &str) {
972        // betting_replace_orders_success_multi.json contains a streaming OCM,
973        // not a REST ReplaceExecutionReport, so it is excluded
974        let data = load_test_json(fixture);
975        let _resp: ReplaceExecutionReport = parse_jsonrpc(&data);
976    }
977
978    #[rstest]
979    #[case("rest/list_current_orders_empty.json")]
980    #[case("rest/list_current_orders_single.json")]
981    #[case("rest/list_current_orders_executable.json")]
982    #[case("rest/list_current_orders_execution_complete.json")]
983    #[case("rest/list_current_orders_on_close_execution_complete.json")]
984    fn test_current_orders(#[case] fixture: &str) {
985        let data = load_test_json(fixture);
986        let _resp: CurrentOrderSummaryReport = parse_jsonrpc(&data);
987    }
988
989    #[rstest]
990    fn test_cleared_orders() {
991        let data = load_test_json("rest/list_cleared_orders.json");
992        let _resp: ClearedOrderSummaryReport = parse_jsonrpc(&data);
993    }
994
995    #[rstest]
996    fn test_betting_market_catalogue() {
997        let data = load_test_json("rest/betting_list_market_catalogue.json");
998        let catalogues: Vec<MarketCatalogue> = serde_json::from_str(&data).unwrap();
999        assert!(!catalogues.is_empty());
1000    }
1001}