Skip to main content

paft_prediction/
market.rs

1//! Prediction event and market metadata models.
2
3use crate::error::{
4    BinaryMarketOutcomeMismatch, MultiOutcomeMarketOutcomeMismatch, PredictionError,
5};
6use crate::identifiers::{PredictionOutcomeId, validate_opaque_identifier};
7use crate::instrument::{
8    BinaryMarketKey, BinaryOutcomeInstruments, OutcomeInstrument, PredictionEventKey,
9    PredictionMarketKey, PredictionSeriesKey,
10};
11use crate::price::{NonZeroContractQuantity, NonZeroOutcomePayout, OutcomePayout, PriceGrid};
12use chrono::{DateTime, Utc};
13use paft_decimal::Decimal;
14use paft_money::Currency;
15use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
16use smol_str::SmolStr;
17use std::{collections::HashSet, fmt, str::FromStr};
18
19macro_rules! opaque_metadata_code {
20    (
21        $(#[$meta:meta])*
22        pub struct $name:ident;
23        kind = $kind:literal;
24        modeled_by = $modeled_by:path;
25    ) => {
26        $(#[$meta])*
27        #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
28        pub struct $name(SmolStr);
29
30        impl $name {
31            /// Construct a new provider-specific metadata code.
32            ///
33            /// # Errors
34            ///
35            /// Returns [`PredictionError::InvalidIdentifier`] when the trimmed
36            /// code is empty, too long, or contains whitespace/control characters.
37            /// Returns [`PredictionError::ModeledMetadataCode`] when the code
38            /// already names a modeled value for the owning enum.
39            pub fn new(input: &str) -> Result<Self, PredictionError> {
40                let value = validate_opaque_identifier($kind, input)?;
41                if $modeled_by(value.as_str()) {
42                    return Err(PredictionError::modeled_metadata_code($kind, input.to_string()));
43                }
44
45                Ok(Self(value))
46            }
47
48            /// Returns the preserved provider metadata code.
49            #[must_use]
50            pub fn as_str(&self) -> &str {
51                self.0.as_str()
52            }
53        }
54
55        impl AsRef<str> for $name {
56            fn as_ref(&self) -> &str {
57                self.as_str()
58            }
59        }
60
61        impl FromStr for $name {
62            type Err = PredictionError;
63
64            fn from_str(input: &str) -> Result<Self, Self::Err> {
65                Self::new(input)
66            }
67        }
68
69        impl fmt::Display for $name {
70            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71                f.write_str(self.as_str())
72            }
73        }
74
75        impl Serialize for $name {
76            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
77            where
78                S: Serializer,
79            {
80                serializer.serialize_str(self.as_str())
81            }
82        }
83
84        impl<'de> Deserialize<'de> for $name {
85            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
86            where
87                D: Deserializer<'de>,
88            {
89                let raw = String::deserialize(deserializer)?;
90                Self::new(&raw).map_err(de::Error::custom)
91            }
92        }
93    };
94}
95
96opaque_metadata_code!(
97    /// Event-structure code not modeled by [`EventStructure`].
98    pub struct OtherEventStructure;
99    kind = "event structure code";
100    modeled_by = is_modeled_event_structure_code;
101);
102
103opaque_metadata_code!(
104    /// Linked-binary relation code not modeled by [`LinkedBinaryRelation`].
105    pub struct OtherLinkedBinaryRelation;
106    kind = "linked binary relation code";
107    modeled_by = is_modeled_linked_binary_relation_code;
108);
109
110opaque_metadata_code!(
111    /// Claim descriptor code not modeled by [`ClaimDescriptor`].
112    pub struct OtherClaimDescriptor;
113    kind = "claim descriptor code";
114    modeled_by = is_modeled_claim_descriptor_code;
115);
116
117opaque_metadata_code!(
118    /// Market-status code not modeled by [`PredictionMarketStatus`].
119    pub struct OtherPredictionMarketStatus;
120    kind = "prediction market status code";
121    modeled_by = is_modeled_prediction_market_status_code;
122);
123
124opaque_metadata_code!(
125    /// Binary-settlement code not modeled by [`BinarySettlement`].
126    pub struct OtherBinarySettlement;
127    kind = "binary settlement code";
128    modeled_by = is_modeled_binary_settlement_code;
129);
130
131fn is_modeled_code(input: &str, modeled: &[&str]) -> bool {
132    modeled.iter().any(|code| input.eq_ignore_ascii_case(code))
133}
134
135fn is_modeled_event_structure_code(input: &str) -> bool {
136    is_modeled_code(
137        input,
138        &[
139            "single_market",
140            "independent_claims",
141            "mutually_exclusive",
142            "ordered_buckets",
143            "linked_binary_claims",
144            "composite",
145        ],
146    )
147}
148
149fn is_modeled_linked_binary_relation_code(input: &str) -> bool {
150    is_modeled_code(
151        input,
152        &["sum_to_one", "negative_risk_conversion", "composite_legs"],
153    )
154}
155
156fn is_modeled_claim_descriptor_code(input: &str) -> bool {
157    is_modeled_code(
158        input,
159        &["text", "categorical", "numeric_range", "composite"],
160    )
161}
162
163fn is_modeled_prediction_market_status_code(input: &str) -> bool {
164    is_modeled_code(
165        input,
166        &[
167            "upcoming",
168            "open",
169            "paused",
170            "closed",
171            "resolved",
172            "cancelled",
173        ],
174    )
175}
176
177fn is_modeled_binary_settlement_code(input: &str) -> bool {
178    is_modeled_code(input, &["yes", "no", "payout_vector", "void"])
179}
180
181macro_rules! open_string_metadata_enum {
182    (
183        $name:ident, $other:ident, $kind:literal;
184        {
185            $($code:literal => $variant:ident),+ $(,)?
186        }
187    ) => {
188        impl $name {
189            /// Returns the stable string code for this enum value.
190            #[must_use]
191            pub fn code(&self) -> &str {
192                match self {
193                    $(Self::$variant => $code,)+
194                    Self::Other(value) => value.as_str(),
195                }
196            }
197
198            /// Parses a string code, preserving unknown values in `Other`.
199            ///
200            /// # Errors
201            ///
202            /// Returns [`PredictionError::InvalidIdentifier`] when the code is
203            /// empty, too long, or contains whitespace/control characters.
204            pub fn try_from_str(input: &str) -> Result<Self, PredictionError> {
205                let value = validate_opaque_identifier($kind, input)?;
206                $(if value.as_str().eq_ignore_ascii_case($code) {
207                    return Ok(Self::$variant);
208                })+
209
210                $other::new(value.as_str()).map(Self::Other)
211            }
212        }
213
214        impl FromStr for $name {
215            type Err = PredictionError;
216
217            fn from_str(input: &str) -> Result<Self, Self::Err> {
218                Self::try_from_str(input)
219            }
220        }
221
222        impl fmt::Display for $name {
223            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224                f.write_str(self.code())
225            }
226        }
227
228        impl Serialize for $name {
229            fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
230            where
231                S: Serializer,
232            {
233                serializer.serialize_str(self.code())
234            }
235        }
236
237        impl<'de> Deserialize<'de> for $name {
238            fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
239            where
240                D: Deserializer<'de>,
241            {
242                let raw = String::deserialize(deserializer)?;
243                Self::try_from_str(&raw).map_err(de::Error::custom)
244            }
245        }
246    };
247}
248
249/// High-level relationship among markets inside an event.
250#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
251#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
252#[non_exhaustive]
253pub enum EventStructure {
254    /// Event is intended to group exactly one market.
255    SingleMarket,
256    /// Markets are related only by provider grouping/context.
257    IndependentClaims,
258    /// Markets are intended to represent mutually exclusive outcomes.
259    MutuallyExclusive {
260        /// Whether the outcome set is exhaustive.
261        exhaustive: bool,
262    },
263    /// Binary markets are intended to represent ordered numeric buckets.
264    OrderedBuckets {
265        /// Whether the bucket set is exhaustive.
266        exhaustive: bool,
267    },
268    /// Markets are intended to be linked by a named binary-claim relation.
269    LinkedBinaryClaims {
270        /// The relation that links the binary claims.
271        relation: LinkedBinaryRelation,
272    },
273    /// Event has composite/conditional structure not captured by simpler variants.
274    Composite,
275    /// Provider-specific event relation.
276    Other {
277        /// Preserved provider event-structure code.
278        value: OtherEventStructure,
279    },
280}
281
282/// Relationship among linked binary claims.
283#[derive(Debug, Clone, PartialEq, Eq, Hash)]
284#[non_exhaustive]
285pub enum LinkedBinaryRelation {
286    /// Linked outcomes sum to one unit of payout/probability.
287    SumToOne,
288    /// Provider supports negative-risk conversion among binary claims.
289    NegativeRiskConversion,
290    /// Linked markets represent legs of a composite claim.
291    CompositeLegs,
292    /// Provider-specific linked-claim relation.
293    Other(OtherLinkedBinaryRelation),
294}
295
296open_string_metadata_enum!(
297    LinkedBinaryRelation, OtherLinkedBinaryRelation, "linked binary relation code";
298    {
299        "sum_to_one" => SumToOne,
300        "negative_risk_conversion" => NegativeRiskConversion,
301        "composite_legs" => CompositeLegs,
302    }
303);
304
305/// Provider-agnostic status of a prediction market.
306#[derive(Debug, Clone, PartialEq, Eq, Hash)]
307#[non_exhaustive]
308pub enum PredictionMarketStatus {
309    /// Market is listed but not yet open.
310    Upcoming,
311    /// Market is accepting orders.
312    Open,
313    /// Market is temporarily paused.
314    Paused,
315    /// Market is closed to trading.
316    Closed,
317    /// Market has resolved.
318    Resolved,
319    /// Market was cancelled/voided by the venue.
320    Cancelled,
321    /// Provider-specific status.
322    Other(OtherPredictionMarketStatus),
323}
324
325open_string_metadata_enum!(
326    PredictionMarketStatus, OtherPredictionMarketStatus, "prediction market status code";
327    {
328        "upcoming" => Upcoming,
329        "open" => Open,
330        "paused" => Paused,
331        "closed" => Closed,
332        "resolved" => Resolved,
333        "cancelled" => Cancelled,
334    }
335);
336
337/// Explicit settlement payouts for a binary market.
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
339#[serde(deny_unknown_fields)]
340pub struct BinaryPayoutVector {
341    /// Resolved payout for a YES outcome instrument.
342    pub yes: OutcomePayout,
343    /// Resolved payout for a NO outcome instrument.
344    pub no: OutcomePayout,
345}
346
347impl BinaryPayoutVector {
348    /// Build an explicit binary settlement payout vector.
349    #[must_use]
350    pub const fn new(yes: OutcomePayout, no: OutcomePayout) -> Self {
351        Self { yes, no }
352    }
353}
354
355/// Final binary market settlement.
356#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
357#[serde(
358    tag = "kind",
359    content = "value",
360    rename_all = "snake_case",
361    deny_unknown_fields
362)]
363#[non_exhaustive]
364pub enum BinarySettlement {
365    /// YES pays the market's full winning payout; NO pays zero.
366    Yes,
367    /// NO pays the market's full winning payout; YES pays zero.
368    No,
369    /// Explicit payout vector, including partial, cancelled, or scalar-like settlement.
370    PayoutVector(BinaryPayoutVector),
371    /// Market voided/cancelled without a simple payout vector.
372    Void,
373    /// Provider-specific binary settlement result.
374    Other(OtherBinarySettlement),
375}
376
377impl BinarySettlement {
378    /// Derive YES/NO payouts for settlements that define a payout vector.
379    #[must_use]
380    pub const fn resolved_payouts(
381        &self,
382        winning_payout: NonZeroOutcomePayout,
383    ) -> Option<BinaryPayoutVector> {
384        match self {
385            Self::Yes => Some(BinaryPayoutVector {
386                yes: winning_payout.to_payout(),
387                no: OutcomePayout::ZERO,
388            }),
389            Self::No => Some(BinaryPayoutVector {
390                yes: OutcomePayout::ZERO,
391                no: winning_payout.to_payout(),
392            }),
393            Self::PayoutVector(vector) => Some(*vector),
394            Self::Void | Self::Other(_) => None,
395        }
396    }
397}
398
399/// Description of the claim represented by a prediction market.
400#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
401#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
402#[non_exhaustive]
403pub enum ClaimDescriptor {
404    /// Free-form textual claim.
405    Text {
406        /// Claim description or resolution condition.
407        description: String,
408    },
409    /// Categorical label supplied by the adapter/provider.
410    Categorical {
411        /// Category or answer label.
412        label: String,
413    },
414    /// Numeric interval claim.
415    NumericRange {
416        /// Parsed numeric interval.
417        range: NumericRange,
418        /// Optional display label for the interval.
419        label: Option<String>,
420    },
421    /// Composite claim that should be interpreted with provider metadata.
422    Composite,
423    /// Provider-specific claim descriptor.
424    Other {
425        /// Preserved provider claim-descriptor code.
426        value: OtherClaimDescriptor,
427    },
428}
429
430/// Parsed numeric interval for a range/bucket market.
431#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
432pub struct NumericRange {
433    /// Lower interval bound.
434    lower: NumericBound,
435    /// Upper interval bound.
436    upper: NumericBound,
437    /// Optional unit for the numeric value, such as `USD`.
438    unit: Option<String>,
439}
440
441impl NumericRange {
442    /// Construct a numeric range and validate its interval.
443    ///
444    /// # Errors
445    ///
446    /// Returns [`PredictionError::InvalidNumericRange`] when finite bounds are
447    /// descending or when equal finite endpoints do not include the single
448    /// endpoint value.
449    pub fn new(
450        lower: NumericBound,
451        upper: NumericBound,
452        unit: Option<String>,
453    ) -> Result<Self, PredictionError> {
454        let range = Self { lower, upper, unit };
455        range.validate()?;
456        Ok(range)
457    }
458
459    /// Return the lower interval bound.
460    #[must_use]
461    pub const fn lower(&self) -> &NumericBound {
462        &self.lower
463    }
464
465    /// Return the upper interval bound.
466    #[must_use]
467    pub const fn upper(&self) -> &NumericBound {
468        &self.upper
469    }
470
471    /// Return the optional unit for the numeric value, such as `USD`.
472    #[must_use]
473    pub fn unit(&self) -> Option<&str> {
474        self.unit.as_deref()
475    }
476
477    /// Consume this range and return its validated parts.
478    #[must_use]
479    pub fn into_parts(self) -> (NumericBound, NumericBound, Option<String>) {
480        (self.lower, self.upper, self.unit)
481    }
482
483    /// Validate the range interval.
484    ///
485    /// # Errors
486    ///
487    /// Returns [`PredictionError::InvalidNumericRange`] when finite bounds are
488    /// descending or when equal finite endpoints do not include the single
489    /// endpoint value.
490    pub fn validate(&self) -> Result<(), PredictionError> {
491        let Some((lower, lower_included)) = self.lower.finite_value() else {
492            return Ok(());
493        };
494        let Some((upper, upper_included)) = self.upper.finite_value() else {
495            return Ok(());
496        };
497
498        if lower > upper {
499            return Err(PredictionError::InvalidNumericRange {
500                reason: "lower bound must be less than or equal to upper bound",
501            });
502        }
503
504        if lower == upper && !(lower_included && upper_included) {
505            return Err(PredictionError::InvalidNumericRange {
506                reason: "zero-width finite ranges must include both endpoints",
507            });
508        }
509
510        Ok(())
511    }
512}
513
514impl<'de> Deserialize<'de> for NumericRange {
515    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
516    where
517        D: Deserializer<'de>,
518    {
519        #[derive(Deserialize)]
520        #[serde(deny_unknown_fields)]
521        struct NumericRangeShadow {
522            lower: NumericBound,
523            upper: NumericBound,
524            unit: Option<String>,
525        }
526
527        let shadow = NumericRangeShadow::deserialize(deserializer)?;
528        Self::new(shadow.lower, shadow.upper, shadow.unit).map_err(de::Error::custom)
529    }
530}
531
532/// Inclusive, exclusive, or unbounded numeric range endpoint.
533#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
534#[serde(rename_all = "snake_case")]
535#[non_exhaustive]
536pub enum NumericBound {
537    /// Inclusive endpoint.
538    Included(#[serde(with = "paft_decimal::serde::canonical_str")] Decimal),
539    /// Exclusive endpoint.
540    Excluded(#[serde(with = "paft_decimal::serde::canonical_str")] Decimal),
541    /// Unbounded endpoint.
542    Unbounded,
543}
544
545impl NumericBound {
546    const fn finite_value(&self) -> Option<(&Decimal, bool)> {
547        match self {
548            Self::Included(value) => Some((value, true)),
549            Self::Excluded(value) => Some((value, false)),
550            Self::Unbounded => None,
551        }
552    }
553}
554
555/// Grouping/container for related prediction markets.
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
557#[serde(bound(deserialize = "E: Default + Deserialize<'de>, M: Default + Deserialize<'de>"))]
558pub struct GenericPredictionEvent<E = (), M = ()> {
559    /// Venue-namespaced event key.
560    pub key: PredictionEventKey,
561    /// Main event title.
562    pub title: String,
563    /// Optional event subtitle/secondary text.
564    pub subtitle: Option<String>,
565    /// Optional provider/category topic.
566    pub category: Option<String>,
567    /// Optional venue-namespaced recurring-series/group key.
568    pub series_key: Option<PredictionSeriesKey>,
569    /// Relationship metadata for the event's markets.
570    ///
571    /// This is not enforced by construction because events are often built
572    /// before their market list is populated. Call [`Self::validate_structure`]
573    /// after filling `markets` to check currently modeled cardinality rules.
574    pub structure: EventStructure,
575    /// Markets grouped under this event.
576    pub markets: Vec<GenericPredictionMarket<M>>,
577    /// Provider-specific payload, flattened into the serialized form.
578    #[serde(flatten, default = "Default::default")]
579    pub provider: E,
580}
581
582impl<E: Default, M> GenericPredictionEvent<E, M> {
583    /// Build an empty prediction event with the given key, title, and structure.
584    ///
585    /// The event starts with no markets even when `structure` implies a market
586    /// cardinality. Populate `markets`, then call [`Self::validate_structure`]
587    /// when the event is expected to be semantically complete.
588    #[must_use]
589    pub fn new(key: PredictionEventKey, title: String, structure: EventStructure) -> Self {
590        Self {
591            key,
592            title,
593            subtitle: None,
594            category: None,
595            series_key: None,
596            structure,
597            markets: Vec::new(),
598            provider: E::default(),
599        }
600    }
601}
602
603impl<E, M> GenericPredictionEvent<E, M> {
604    /// Validate that the event's market count matches modeled structure rules.
605    ///
606    /// This intentionally validates only structure-level cardinality. It does
607    /// not try to prove provider-specific semantics, such as whether an
608    /// ordered-bucket event's binary claims all carry numeric range descriptors.
609    ///
610    /// # Errors
611    ///
612    /// Returns [`PredictionError::InvalidEventStructure`] when the event's
613    /// market count is inconsistent with its [`EventStructure`].
614    pub fn validate_structure(&self) -> Result<(), PredictionError> {
615        validate_event_structure(&self.structure, self.markets.len())
616    }
617}
618
619fn validate_event_structure(
620    structure: &EventStructure,
621    market_count: usize,
622) -> Result<(), PredictionError> {
623    let invalid = |structure, reason| PredictionError::InvalidEventStructure {
624        structure,
625        market_count,
626        reason,
627    };
628
629    match structure {
630        EventStructure::SingleMarket if market_count != 1 => Err(invalid(
631            "single_market",
632            "expected exactly one contained market",
633        )),
634        EventStructure::MutuallyExclusive { .. } if market_count < 2 => Err(invalid(
635            "mutually_exclusive",
636            "expected at least two contained markets",
637        )),
638        EventStructure::OrderedBuckets { .. } if market_count < 2 => Err(invalid(
639            "ordered_buckets",
640            "expected at least two contained markets",
641        )),
642        EventStructure::LinkedBinaryClaims { .. } if market_count < 2 => Err(invalid(
643            "linked_binary_claims",
644            "expected at least two contained markets",
645        )),
646        EventStructure::SingleMarket
647        | EventStructure::IndependentClaims
648        | EventStructure::MutuallyExclusive { .. }
649        | EventStructure::OrderedBuckets { .. }
650        | EventStructure::LinkedBinaryClaims { .. }
651        | EventStructure::Composite
652        | EventStructure::Other { .. } => Ok(()),
653    }
654}
655
656/// Standard prediction event with no provider metadata.
657pub type PredictionEvent = GenericPredictionEvent<(), ()>;
658
659/// Prediction market shape.
660#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(tag = "kind", content = "market", rename_all = "snake_case")]
662#[serde(bound(deserialize = "M: Default + Deserialize<'de>"))]
663#[non_exhaustive]
664// Keep market variants inline so construction and pattern matching stay direct;
665// this is metadata, not a high-cardinality in-memory book representation.
666#[expect(
667    clippy::large_enum_variant,
668    reason = "market metadata variants stay inline for direct construction and pattern matching"
669)]
670pub enum GenericPredictionMarket<M = ()> {
671    /// Atomic yes/no claim.
672    Binary(GenericBinaryMarket<M>),
673    /// Native multi-answer market.
674    MultiOutcome(GenericMultiOutcomeMarket<M>),
675    /// Scalar/numeric market.
676    Scalar(GenericScalarMarket<M>),
677}
678
679/// Standard prediction market enum with no provider metadata.
680pub type PredictionMarket = GenericPredictionMarket<()>;
681
682/// Atomic yes/no prediction market metadata.
683#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
684pub struct GenericBinaryMarket<M = ()> {
685    /// Venue-namespaced binary market key.
686    key: BinaryMarketKey,
687    /// Required tradable YES and NO outcome instruments for this market.
688    outcomes: BinaryOutcomeInstruments,
689    /// Venue-namespaced event/group key when this market belongs to an event.
690    pub event_key: Option<PredictionEventKey>,
691    /// Market title.
692    pub title: String,
693    /// Optional label for the YES outcome.
694    pub yes_label: Option<String>,
695    /// Optional label for the NO outcome.
696    pub no_label: Option<String>,
697    /// Claim descriptor, optionally structured by an adapter.
698    pub claim: ClaimDescriptor,
699    /// Market lifecycle status.
700    pub status: PredictionMarketStatus,
701    /// Currency used for collateral and settlement.
702    pub collateral_currency: Currency,
703    /// Non-zero payout per winning contract/share, denominated by `collateral_currency`.
704    pub winning_payout: NonZeroOutcomePayout,
705    /// Market-specific price grid, if known.
706    pub price_grid: Option<PriceGrid>,
707    /// Non-zero minimum accepted order quantity, if known.
708    pub min_order_quantity: Option<NonZeroContractQuantity>,
709    /// Time when the market opens to trading.
710    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
711    pub open_time: Option<DateTime<Utc>>,
712    /// Time when the market closes to trading.
713    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
714    pub close_time: Option<DateTime<Utc>>,
715    /// Time when the market settled/resolved.
716    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
717    pub settlement_time: Option<DateTime<Utc>>,
718    /// Binary settlement result, if known.
719    pub settlement: Option<BinarySettlement>,
720    /// Provider-specific payload, flattened into the serialized form.
721    #[serde(flatten, default = "Default::default")]
722    pub provider: M,
723}
724
725impl<M: Default> GenericBinaryMarket<M> {
726    /// Build a binary market with the minimum required metadata.
727    ///
728    /// # Errors
729    ///
730    /// Returns [`PredictionError::MismatchedBinaryMarketOutcomes`] when the
731    /// outcome instruments do not belong to `key`.
732    pub fn new(
733        key: BinaryMarketKey,
734        outcomes: BinaryOutcomeInstruments,
735        title: String,
736        claim: ClaimDescriptor,
737        status: PredictionMarketStatus,
738        collateral_currency: Currency,
739        winning_payout: NonZeroOutcomePayout,
740    ) -> Result<Self, PredictionError> {
741        validate_binary_market_outcomes(&key, &outcomes)?;
742
743        Ok(Self {
744            key,
745            outcomes,
746            event_key: None,
747            title,
748            yes_label: None,
749            no_label: None,
750            claim,
751            status,
752            collateral_currency,
753            winning_payout,
754            price_grid: None,
755            min_order_quantity: None,
756            open_time: None,
757            close_time: None,
758            settlement_time: None,
759            settlement: None,
760            provider: M::default(),
761        })
762    }
763}
764
765impl<'de, M> Deserialize<'de> for GenericBinaryMarket<M>
766where
767    M: Default + Deserialize<'de>,
768{
769    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
770    where
771        D: Deserializer<'de>,
772    {
773        #[derive(Deserialize)]
774        struct GenericBinaryMarketShadow<M> {
775            key: BinaryMarketKey,
776            outcomes: BinaryOutcomeInstruments,
777            event_key: Option<PredictionEventKey>,
778            title: String,
779            yes_label: Option<String>,
780            no_label: Option<String>,
781            claim: ClaimDescriptor,
782            status: PredictionMarketStatus,
783            collateral_currency: Currency,
784            winning_payout: NonZeroOutcomePayout,
785            price_grid: Option<PriceGrid>,
786            min_order_quantity: Option<NonZeroContractQuantity>,
787            #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
788            open_time: Option<DateTime<Utc>>,
789            #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
790            close_time: Option<DateTime<Utc>>,
791            #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
792            settlement_time: Option<DateTime<Utc>>,
793            settlement: Option<BinarySettlement>,
794            #[serde(flatten, default = "Default::default")]
795            provider: M,
796        }
797
798        let shadow = GenericBinaryMarketShadow::deserialize(deserializer)?;
799        validate_binary_market_outcomes(&shadow.key, &shadow.outcomes)
800            .map_err(de::Error::custom)?;
801
802        Ok(Self {
803            key: shadow.key,
804            outcomes: shadow.outcomes,
805            event_key: shadow.event_key,
806            title: shadow.title,
807            yes_label: shadow.yes_label,
808            no_label: shadow.no_label,
809            claim: shadow.claim,
810            status: shadow.status,
811            collateral_currency: shadow.collateral_currency,
812            winning_payout: shadow.winning_payout,
813            price_grid: shadow.price_grid,
814            min_order_quantity: shadow.min_order_quantity,
815            open_time: shadow.open_time,
816            close_time: shadow.close_time,
817            settlement_time: shadow.settlement_time,
818            settlement: shadow.settlement,
819            provider: shadow.provider,
820        })
821    }
822}
823
824impl<M> GenericBinaryMarket<M> {
825    /// Return the venue-namespaced binary market key.
826    #[must_use]
827    pub const fn key(&self) -> &BinaryMarketKey {
828        &self.key
829    }
830
831    /// Return the required tradable YES and NO outcome instruments.
832    #[must_use]
833    pub const fn outcomes(&self) -> &BinaryOutcomeInstruments {
834        &self.outcomes
835    }
836
837    /// Derive resolved YES/NO payouts from this market's settlement, when possible.
838    #[must_use]
839    pub fn resolved_payouts(&self) -> Option<BinaryPayoutVector> {
840        self.settlement
841            .as_ref()?
842            .resolved_payouts(self.winning_payout)
843    }
844}
845
846fn validate_binary_market_outcomes(
847    key: &BinaryMarketKey,
848    outcomes: &BinaryOutcomeInstruments,
849) -> Result<(), PredictionError> {
850    let yes = outcomes.yes();
851    let no = outcomes.no();
852    if yes.venue == key.venue
853        && no.venue == key.venue
854        && yes.market_id == key.market_id
855        && no.market_id == key.market_id
856    {
857        return Ok(());
858    }
859
860    Err(PredictionError::MismatchedBinaryMarketOutcomes(Box::new(
861        BinaryMarketOutcomeMismatch {
862            key_venue: key.venue.to_string(),
863            key_market_id: key.market_id.to_string(),
864            yes_venue: yes.venue.to_string(),
865            yes_market_id: yes.market_id.to_string(),
866            no_venue: no.venue.to_string(),
867            no_market_id: no.market_id.to_string(),
868        },
869    )))
870}
871
872/// Standard binary market with no provider metadata.
873pub type BinaryMarket = GenericBinaryMarket<()>;
874
875/// Native multi-answer market metadata.
876#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
877pub struct GenericMultiOutcomeMarket<M = ()> {
878    /// Venue-namespaced market key.
879    key: PredictionMarketKey,
880    /// Venue-namespaced event/group key when this market belongs to an event.
881    pub event_key: Option<PredictionEventKey>,
882    /// Market title.
883    pub title: String,
884    /// Outcomes available in the market.
885    outcomes: Vec<OutcomeDescriptor>,
886    /// Market lifecycle status.
887    pub status: PredictionMarketStatus,
888    /// Currency used for collateral and settlement.
889    pub collateral_currency: Currency,
890    /// Payout per winning contract/share, denominated by `collateral_currency`.
891    pub unit_payout: OutcomePayout,
892    /// Market-specific price grid, if known.
893    pub price_grid: Option<PriceGrid>,
894    /// Non-zero minimum accepted order quantity, if known.
895    pub min_order_quantity: Option<NonZeroContractQuantity>,
896    /// Time when the market opens to trading.
897    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
898    pub open_time: Option<DateTime<Utc>>,
899    /// Time when the market closes to trading.
900    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
901    pub close_time: Option<DateTime<Utc>>,
902    /// Time when the market settled/resolved.
903    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
904    pub settlement_time: Option<DateTime<Utc>>,
905    /// Winning outcome id, if resolved to one outcome.
906    resolution: Option<PredictionOutcomeId>,
907    /// Provider-specific payload, flattened into the serialized form.
908    #[serde(flatten, default = "Default::default")]
909    pub provider: M,
910}
911
912impl<M: Default> GenericMultiOutcomeMarket<M> {
913    /// Build a native multi-outcome market with the minimum required metadata.
914    ///
915    /// # Errors
916    ///
917    /// Returns [`PredictionError`] when fewer than two outcomes are supplied,
918    /// an outcome instrument does not belong to `key`, or outcome ids repeat.
919    pub fn new(
920        key: PredictionMarketKey,
921        title: String,
922        outcomes: Vec<OutcomeDescriptor>,
923        status: PredictionMarketStatus,
924        collateral_currency: Currency,
925        unit_payout: OutcomePayout,
926    ) -> Result<Self, PredictionError> {
927        validate_multi_outcome_market(&key, &outcomes, None)?;
928
929        Ok(Self {
930            key,
931            event_key: None,
932            title,
933            outcomes,
934            status,
935            collateral_currency,
936            unit_payout,
937            price_grid: None,
938            min_order_quantity: None,
939            open_time: None,
940            close_time: None,
941            settlement_time: None,
942            resolution: None,
943            provider: M::default(),
944        })
945    }
946}
947
948impl<M> GenericMultiOutcomeMarket<M> {
949    /// Return the venue-namespaced market key.
950    #[must_use]
951    pub const fn key(&self) -> &PredictionMarketKey {
952        &self.key
953    }
954
955    /// Return the outcome descriptors in provider order.
956    #[must_use]
957    pub fn outcomes(&self) -> &[OutcomeDescriptor] {
958        &self.outcomes
959    }
960
961    /// Return the winning outcome id, if resolved.
962    #[must_use]
963    pub const fn resolution(&self) -> Option<&PredictionOutcomeId> {
964        self.resolution.as_ref()
965    }
966
967    /// Set or clear the winning outcome id.
968    ///
969    /// # Errors
970    ///
971    /// Returns [`PredictionError::InvalidMultiOutcomeMarketResolution`] when
972    /// `resolution` is not one of the listed outcome ids.
973    pub fn set_resolution(
974        &mut self,
975        resolution: Option<PredictionOutcomeId>,
976    ) -> Result<(), PredictionError> {
977        validate_multi_outcome_market_resolution(&self.key, &self.outcomes, resolution.as_ref())?;
978        self.resolution = resolution;
979        Ok(())
980    }
981}
982
983impl<'de, M> Deserialize<'de> for GenericMultiOutcomeMarket<M>
984where
985    M: Default + Deserialize<'de>,
986{
987    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
988    where
989        D: Deserializer<'de>,
990    {
991        #[derive(Deserialize)]
992        struct GenericMultiOutcomeMarketShadow<M> {
993            key: PredictionMarketKey,
994            event_key: Option<PredictionEventKey>,
995            title: String,
996            outcomes: Vec<OutcomeDescriptor>,
997            status: PredictionMarketStatus,
998            collateral_currency: Currency,
999            unit_payout: OutcomePayout,
1000            price_grid: Option<PriceGrid>,
1001            min_order_quantity: Option<NonZeroContractQuantity>,
1002            #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
1003            open_time: Option<DateTime<Utc>>,
1004            #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
1005            close_time: Option<DateTime<Utc>>,
1006            #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
1007            settlement_time: Option<DateTime<Utc>>,
1008            resolution: Option<PredictionOutcomeId>,
1009            #[serde(flatten, default = "Default::default")]
1010            provider: M,
1011        }
1012
1013        let shadow = GenericMultiOutcomeMarketShadow::deserialize(deserializer)?;
1014        validate_multi_outcome_market(&shadow.key, &shadow.outcomes, shadow.resolution.as_ref())
1015            .map_err(de::Error::custom)?;
1016
1017        Ok(Self {
1018            key: shadow.key,
1019            event_key: shadow.event_key,
1020            title: shadow.title,
1021            outcomes: shadow.outcomes,
1022            status: shadow.status,
1023            collateral_currency: shadow.collateral_currency,
1024            unit_payout: shadow.unit_payout,
1025            price_grid: shadow.price_grid,
1026            min_order_quantity: shadow.min_order_quantity,
1027            open_time: shadow.open_time,
1028            close_time: shadow.close_time,
1029            settlement_time: shadow.settlement_time,
1030            resolution: shadow.resolution,
1031            provider: shadow.provider,
1032        })
1033    }
1034}
1035
1036fn validate_multi_outcome_market(
1037    key: &PredictionMarketKey,
1038    outcomes: &[OutcomeDescriptor],
1039    resolution: Option<&PredictionOutcomeId>,
1040) -> Result<(), PredictionError> {
1041    if outcomes.len() < 2 {
1042        return Err(PredictionError::TooFewMultiOutcomeMarketOutcomes {
1043            count: outcomes.len(),
1044        });
1045    }
1046
1047    let mut outcome_ids = HashSet::with_capacity(outcomes.len());
1048    for outcome in outcomes {
1049        let instrument = &outcome.instrument;
1050        if instrument.venue != key.venue || instrument.market_id != key.market_id {
1051            return Err(PredictionError::MismatchedMultiOutcomeMarketOutcome(
1052                Box::new(MultiOutcomeMarketOutcomeMismatch {
1053                    key_venue: key.venue.to_string(),
1054                    key_market_id: key.market_id.to_string(),
1055                    outcome_venue: instrument.venue.to_string(),
1056                    outcome_market_id: instrument.market_id.to_string(),
1057                    outcome_id: instrument.outcome_id.to_string(),
1058                }),
1059            ));
1060        }
1061
1062        if !outcome_ids.insert(&instrument.outcome_id) {
1063            return Err(PredictionError::DuplicateMultiOutcomeMarketOutcome {
1064                venue: instrument.venue.to_string(),
1065                market_id: instrument.market_id.to_string(),
1066                outcome_id: instrument.outcome_id.to_string(),
1067            });
1068        }
1069    }
1070
1071    validate_multi_outcome_market_resolution(key, outcomes, resolution)
1072}
1073
1074fn validate_multi_outcome_market_resolution(
1075    key: &PredictionMarketKey,
1076    outcomes: &[OutcomeDescriptor],
1077    resolution: Option<&PredictionOutcomeId>,
1078) -> Result<(), PredictionError> {
1079    let Some(resolution) = resolution else {
1080        return Ok(());
1081    };
1082
1083    if outcomes
1084        .iter()
1085        .any(|outcome| outcome.instrument.outcome_id == *resolution)
1086    {
1087        return Ok(());
1088    }
1089
1090    Err(PredictionError::InvalidMultiOutcomeMarketResolution {
1091        venue: key.venue.to_string(),
1092        market_id: key.market_id.to_string(),
1093        outcome_id: resolution.to_string(),
1094    })
1095}
1096
1097/// Standard multi-outcome market with no provider metadata.
1098pub type MultiOutcomeMarket = GenericMultiOutcomeMarket<()>;
1099
1100/// Outcome metadata inside a native multi-answer market.
1101#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1102pub struct OutcomeDescriptor {
1103    /// Tradable outcome instrument.
1104    pub instrument: OutcomeInstrument,
1105    /// Human-readable outcome label.
1106    pub label: String,
1107}
1108
1109/// Scalar/numeric prediction market metadata.
1110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1111pub struct GenericScalarMarket<M = ()> {
1112    /// Venue-namespaced market key.
1113    pub key: PredictionMarketKey,
1114    /// Venue-namespaced event/group key when this market belongs to an event.
1115    pub event_key: Option<PredictionEventKey>,
1116    /// Market title.
1117    pub title: String,
1118    /// Optional unit for the resolved scalar value.
1119    pub unit: Option<String>,
1120    /// Market lifecycle status.
1121    pub status: PredictionMarketStatus,
1122    /// Time when the market opens to trading.
1123    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
1124    pub open_time: Option<DateTime<Utc>>,
1125    /// Time when the market closes to trading.
1126    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
1127    pub close_time: Option<DateTime<Utc>>,
1128    /// Time when the market settled/resolved.
1129    #[serde(default, with = "chrono::serde::ts_milliseconds_option")]
1130    pub settlement_time: Option<DateTime<Utc>>,
1131    /// Resolved scalar value, if known.
1132    #[serde(default, with = "paft_decimal::serde::option_canonical_str")]
1133    pub resolution_value: Option<Decimal>,
1134    /// Provider-specific payload, flattened into the serialized form.
1135    #[serde(flatten, default = "Default::default")]
1136    pub provider: M,
1137}
1138
1139/// Standard scalar market with no provider metadata.
1140pub type ScalarMarket = GenericScalarMarket<()>;