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