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