Skip to main content

nautilus_infrastructure/sql/models/
enums.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
16use std::str::FromStr;
17
18use nautilus_model::enums::{
19    AggregationSource, AggressorSide, AssetClass, BarAggregation, CurrencyType, PriceType,
20    TrailingOffsetType,
21};
22use sqlx::{
23    Database, Decode, Postgres, encode::IsNull, error::BoxDynError, postgres::PgTypeInfo,
24    types::Type,
25};
26
27#[derive(Debug)]
28pub struct CurrencyTypePg(pub CurrencyType);
29
30#[derive(Debug)]
31pub struct PriceTypePg(pub PriceType);
32
33#[derive(Debug)]
34pub struct BarAggregationPg(pub BarAggregation);
35
36#[derive(Debug)]
37pub struct AssetClassPg(pub AssetClass);
38
39#[derive(Debug)]
40pub struct TrailingOffsetTypePg(pub Option<TrailingOffsetType>);
41
42#[derive(Debug)]
43pub struct AggressorSidePg(pub AggressorSide);
44
45#[derive(Debug)]
46pub struct AggregationSourcePg(pub AggregationSource);
47
48impl sqlx::Encode<'_, sqlx::Postgres> for CurrencyTypePg {
49    fn encode_by_ref(
50        &self,
51        buf: &mut <Postgres as Database>::ArgumentBuffer,
52    ) -> Result<IsNull, BoxDynError> {
53        let currency_type_str = match self.0 {
54            CurrencyType::Crypto => "CRYPTO",
55            CurrencyType::Fiat => "FIAT",
56            CurrencyType::CommodityBacked => "COMMODITY_BACKED",
57        };
58        <&str as sqlx::Encode<sqlx::Postgres>>::encode(currency_type_str, buf)
59    }
60}
61
62impl<'r> sqlx::Decode<'r, sqlx::Postgres> for CurrencyTypePg {
63    fn decode(value: <Postgres as Database>::ValueRef<'r>) -> Result<Self, BoxDynError> {
64        let currency_type_str: &str = <&str as Decode<sqlx::Postgres>>::decode(value)?;
65        let currency_type = CurrencyType::from_str(currency_type_str).map_err(|_| {
66            sqlx::Error::Decode(format!("Invalid currency type: {currency_type_str}").into())
67        })?;
68        Ok(Self(currency_type))
69    }
70}
71
72impl sqlx::Type<sqlx::Postgres> for CurrencyTypePg {
73    fn type_info() -> sqlx::postgres::PgTypeInfo {
74        PgTypeInfo::with_name("currency_type")
75    }
76
77    fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
78        *ty == Self::type_info() || <&str as Type<sqlx::Postgres>>::compatible(ty)
79    }
80}
81
82impl sqlx::Encode<'_, sqlx::Postgres> for AssetClassPg {
83    fn encode_by_ref(
84        &self,
85        buf: &mut <Postgres as Database>::ArgumentBuffer,
86    ) -> Result<IsNull, BoxDynError> {
87        let asset_type_str = match self.0 {
88            AssetClass::FX => "FX",
89            AssetClass::Equity => "EQUITY",
90            AssetClass::Commodity => "COMMODITY",
91            AssetClass::Debt => "DEBT",
92            AssetClass::Index => "INDEX",
93            AssetClass::Cryptocurrency => "CRYPTOCURRENCY",
94            AssetClass::Alternative => "ALTERNATIVE",
95        };
96        <&str as sqlx::Encode<sqlx::Postgres>>::encode(asset_type_str, buf)
97    }
98}
99
100impl<'r> sqlx::Decode<'r, sqlx::Postgres> for AssetClassPg {
101    fn decode(value: <Postgres as Database>::ValueRef<'r>) -> Result<Self, BoxDynError> {
102        let asset_class_str: &str = <&str as Decode<sqlx::Postgres>>::decode(value)?;
103        let asset_class = AssetClass::from_str(asset_class_str).map_err(|_| {
104            sqlx::Error::Decode(format!("Invalid asset class: {asset_class_str}").into())
105        })?;
106        Ok(Self(asset_class))
107    }
108}
109
110impl sqlx::Type<sqlx::Postgres> for AssetClassPg {
111    fn type_info() -> sqlx::postgres::PgTypeInfo {
112        PgTypeInfo::with_name("asset_class")
113    }
114
115    fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
116        *ty == Self::type_info() || <&str as Type<sqlx::Postgres>>::compatible(ty)
117    }
118}
119
120impl sqlx::Encode<'_, sqlx::Postgres> for TrailingOffsetTypePg {
121    fn encode_by_ref(
122        &self,
123        buf: &mut <Postgres as Database>::ArgumentBuffer,
124    ) -> Result<IsNull, BoxDynError> {
125        let value = self.0.as_ref().map_or("NO_TRAILING_OFFSET", AsRef::as_ref);
126        <&str as sqlx::Encode<sqlx::Postgres>>::encode(value, buf)
127    }
128}
129
130impl<'r> sqlx::Decode<'r, sqlx::Postgres> for TrailingOffsetTypePg {
131    fn decode(value: <Postgres as Database>::ValueRef<'r>) -> Result<Self, BoxDynError> {
132        let trailing_offset_type_str: &str = <&str as Decode<sqlx::Postgres>>::decode(value)?;
133        let trailing_offset_type = if trailing_offset_type_str == "NO_TRAILING_OFFSET" {
134            None
135        } else {
136            Some(
137                TrailingOffsetType::from_str(trailing_offset_type_str).map_err(|_| {
138                    sqlx::Error::Decode(
139                        format!("Invalid trailing offset type: {trailing_offset_type_str}").into(),
140                    )
141                })?,
142            )
143        };
144        Ok(Self(trailing_offset_type))
145    }
146}
147
148impl sqlx::Type<sqlx::Postgres> for TrailingOffsetTypePg {
149    fn type_info() -> sqlx::postgres::PgTypeInfo {
150        PgTypeInfo::with_name("trailing_offset_type")
151    }
152
153    fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
154        *ty == Self::type_info() || <&str as Type<sqlx::Postgres>>::compatible(ty)
155    }
156}
157
158impl sqlx::Encode<'_, sqlx::Postgres> for AggressorSidePg {
159    fn encode_by_ref(
160        &self,
161        buf: &mut <Postgres as Database>::ArgumentBuffer,
162    ) -> Result<IsNull, BoxDynError> {
163        let aggressor_side_str = match self.0 {
164            AggressorSide::NoAggressor => "NO_AGGRESSOR",
165            AggressorSide::Buy => "BUY",
166            AggressorSide::Sell => "SELL",
167        };
168        <&str as sqlx::Encode<sqlx::Postgres>>::encode(aggressor_side_str, buf)
169    }
170}
171
172impl<'r> sqlx::Decode<'r, sqlx::Postgres> for AggressorSidePg {
173    fn decode(value: <Postgres as Database>::ValueRef<'r>) -> Result<Self, BoxDynError> {
174        let aggressor_side_str: &str = <&str as Decode<sqlx::Postgres>>::decode(value)?;
175        let aggressor_side = AggressorSide::from_str(aggressor_side_str).map_err(|_| {
176            sqlx::Error::Decode(format!("Invalid aggressor side: {aggressor_side_str}").into())
177        })?;
178        Ok(Self(aggressor_side))
179    }
180}
181
182impl sqlx::Type<sqlx::Postgres> for AggressorSidePg {
183    fn type_info() -> sqlx::postgres::PgTypeInfo {
184        PgTypeInfo::with_name("aggressor_side")
185    }
186
187    fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
188        *ty == Self::type_info() || <&str as Type<sqlx::Postgres>>::compatible(ty)
189    }
190}
191
192impl sqlx::Encode<'_, sqlx::Postgres> for AggregationSourcePg {
193    fn encode_by_ref(
194        &self,
195        buf: &mut <Postgres as Database>::ArgumentBuffer,
196    ) -> Result<IsNull, BoxDynError> {
197        let aggregation_source_str = match self.0 {
198            AggregationSource::Internal => "INTERNAL",
199            AggregationSource::External => "EXTERNAL",
200        };
201        <&str as sqlx::Encode<sqlx::Postgres>>::encode(aggregation_source_str, buf)
202    }
203}
204
205impl<'r> sqlx::Decode<'r, sqlx::Postgres> for AggregationSourcePg {
206    fn decode(value: <Postgres as Database>::ValueRef<'r>) -> Result<Self, BoxDynError> {
207        let aggregation_source_str: &str = <&str as Decode<sqlx::Postgres>>::decode(value)?;
208        let aggregation_source =
209            AggregationSource::from_str(aggregation_source_str).map_err(|_| {
210                sqlx::Error::Decode(
211                    format!("Invalid aggregation source: {aggregation_source_str}").into(),
212                )
213            })?;
214        Ok(Self(aggregation_source))
215    }
216}
217
218impl sqlx::Type<sqlx::Postgres> for AggregationSourcePg {
219    fn type_info() -> sqlx::postgres::PgTypeInfo {
220        PgTypeInfo::with_name("aggregation_source")
221    }
222
223    fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
224        *ty == Self::type_info() || <&str as Type<sqlx::Postgres>>::compatible(ty)
225    }
226}
227
228impl sqlx::Encode<'_, sqlx::Postgres> for BarAggregationPg {
229    fn encode_by_ref(
230        &self,
231        buf: &mut <Postgres as Database>::ArgumentBuffer,
232    ) -> Result<IsNull, BoxDynError> {
233        let bar_aggregation_str = match self.0 {
234            BarAggregation::Tick => "TICK",
235            BarAggregation::TickImbalance => "TICK_IMBALANCE",
236            BarAggregation::TickRuns => "TICK_RUNS",
237            BarAggregation::Volume => "VOLUME",
238            BarAggregation::VolumeImbalance => "VOLUME_IMBALANCE",
239            BarAggregation::VolumeRuns => "VOLUME_RUNS",
240            BarAggregation::Value => "VALUE",
241            BarAggregation::ValueImbalance => "VALUE_IMBALANCE",
242            BarAggregation::ValueRuns => "VALUE_RUNS",
243            BarAggregation::Millisecond => "MILLISECOND",
244            BarAggregation::Second => "SECOND",
245            BarAggregation::Minute => "MINUTE",
246            BarAggregation::Hour => "HOUR",
247            BarAggregation::Day => "DAY",
248            BarAggregation::Week => "WEEK",
249            BarAggregation::Month => "MONTH",
250            BarAggregation::Year => "YEAR",
251            BarAggregation::Renko => "RENKO",
252        };
253        <&str as sqlx::Encode<sqlx::Postgres>>::encode(bar_aggregation_str, buf)
254    }
255}
256
257impl<'r> sqlx::Decode<'r, sqlx::Postgres> for BarAggregationPg {
258    fn decode(value: <Postgres as Database>::ValueRef<'r>) -> Result<Self, BoxDynError> {
259        let bar_aggregation_str: &str = <&str as Decode<sqlx::Postgres>>::decode(value)?;
260        let bar_aggregation = BarAggregation::from_str(bar_aggregation_str).map_err(|_| {
261            sqlx::Error::Decode(format!("Invalid bar aggregation: {bar_aggregation_str}").into())
262        })?;
263        Ok(Self(bar_aggregation))
264    }
265}
266
267impl sqlx::Type<sqlx::Postgres> for BarAggregationPg {
268    fn type_info() -> sqlx::postgres::PgTypeInfo {
269        PgTypeInfo::with_name("bar_aggregation")
270    }
271
272    fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
273        *ty == Self::type_info() || <&str as Type<sqlx::Postgres>>::compatible(ty)
274    }
275}
276
277impl sqlx::Encode<'_, sqlx::Postgres> for PriceTypePg {
278    fn encode_by_ref(
279        &self,
280        buf: &mut <Postgres as Database>::ArgumentBuffer,
281    ) -> Result<IsNull, BoxDynError> {
282        let price_type_str = match self.0 {
283            PriceType::Bid => "BID",
284            PriceType::Ask => "ASK",
285            PriceType::Mid => "MID",
286            PriceType::Last => "LAST",
287            PriceType::Mark => "MARK",
288        };
289        <&str as sqlx::Encode<sqlx::Postgres>>::encode(price_type_str, buf)
290    }
291}
292
293impl<'r> sqlx::Decode<'r, sqlx::Postgres> for PriceTypePg {
294    fn decode(value: <Postgres as Database>::ValueRef<'r>) -> Result<Self, BoxDynError> {
295        let price_type_str: &str = <&str as Decode<sqlx::Postgres>>::decode(value)?;
296        let price_type = PriceType::from_str(price_type_str).map_err(|_| {
297            sqlx::Error::Decode(format!("Invalid price type: {price_type_str}").into())
298        })?;
299        Ok(Self(price_type))
300    }
301}
302
303impl sqlx::Type<sqlx::Postgres> for PriceTypePg {
304    fn type_info() -> sqlx::postgres::PgTypeInfo {
305        PgTypeInfo::with_name("price_type")
306    }
307
308    fn compatible(ty: &sqlx::postgres::PgTypeInfo) -> bool {
309        *ty == Self::type_info() || <&str as Type<sqlx::Postgres>>::compatible(ty)
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use nautilus_model::enums::{AccountType, BookAction, InstrumentClass, OrderStatus};
316    use regex::Regex;
317    use rstest::rstest;
318    use strum::IntoEnumIterator;
319
320    use super::*;
321
322    // Reads the shipped schema rather than a copy of it, so a type declared here can never
323    // drift from the Rust enum it mirrors.
324    fn types_sql() -> String {
325        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../schema/sql/types.sql");
326        std::fs::read_to_string(path).expect("failed to read types.sql")
327    }
328
329    fn sql_enum_labels(type_name: &str) -> Vec<String> {
330        let sql = types_sql();
331        let declaration = Regex::new(&format!(
332            r"(?s)CREATE\s+TYPE\s+{type_name}\s+AS\s+ENUM\s*\((.*?)\);"
333        ))
334        .expect("invalid declaration pattern")
335        .captures(&sql)
336        .unwrap_or_else(|| panic!("no CREATE TYPE found for {type_name}"))[1]
337            .to_string();
338
339        Regex::new("'([A-Z_0-9]+)'")
340            .expect("invalid label pattern")
341            .captures_iter(&declaration)
342            .map(|label| label[1].to_string())
343            .collect()
344    }
345
346    fn rust_enum_labels<T: IntoEnumIterator + AsRef<str>>() -> Vec<String> {
347        T::iter().map(|value| value.as_ref().to_string()).collect()
348    }
349
350    // The single source of truth for coverage, so a type declared in types.sql without an entry
351    // here fails `every_declared_sql_enum_type_is_guarded`.
352    fn guarded_sql_enum_types() -> Vec<(&'static str, Vec<String>)> {
353        vec![
354            ("ACCOUNT_TYPE", rust_enum_labels::<AccountType>()),
355            (
356                "AGGREGATION_SOURCE",
357                rust_enum_labels::<AggregationSource>(),
358            ),
359            ("AGGRESSOR_SIDE", rust_enum_labels::<AggressorSide>()),
360            ("ASSET_CLASS", rust_enum_labels::<AssetClass>()),
361            ("BAR_AGGREGATION", rust_enum_labels::<BarAggregation>()),
362            ("BOOK_ACTION", rust_enum_labels::<BookAction>()),
363            ("CURRENCY_TYPE", rust_enum_labels::<CurrencyType>()),
364            ("INSTRUMENT_CLASS", rust_enum_labels::<InstrumentClass>()),
365            ("ORDER_STATUS", rust_enum_labels::<OrderStatus>()),
366            ("PRICE_TYPE", rust_enum_labels::<PriceType>()),
367            (
368                "TRAILING_OFFSET_TYPE",
369                std::iter::once("NO_TRAILING_OFFSET".to_string())
370                    .chain(rust_enum_labels::<TrailingOffsetType>())
371                    .collect(),
372            ),
373        ]
374    }
375
376    #[rstest]
377    fn sql_enum_type_matches_rust_enum() {
378        for (type_name, expected) in guarded_sql_enum_types() {
379            assert_eq!(sql_enum_labels(type_name), expected, "{type_name}");
380        }
381    }
382
383    #[rstest]
384    fn every_declared_sql_enum_type_is_guarded() {
385        let mut declared: Vec<String> = Regex::new(r"CREATE\s+TYPE\s+(\w+)\s+AS\s+ENUM")
386            .expect("invalid type name pattern")
387            .captures_iter(&types_sql())
388            .map(|name| name[1].to_string())
389            .collect();
390        let mut guarded: Vec<String> = guarded_sql_enum_types()
391            .into_iter()
392            .map(|(type_name, _)| type_name.to_string())
393            .collect();
394        declared.sort();
395        guarded.sort();
396
397        assert_eq!(declared, guarded);
398    }
399
400    #[rstest]
401    #[case(AggressorSide::NoAggressor, "NO_AGGRESSOR")]
402    #[case(AggressorSide::Buy, "BUY")]
403    #[case(AggressorSide::Sell, "SELL")]
404    fn aggressor_side_pg_encodes_postgres_labels(
405        #[case] value: AggressorSide,
406        #[case] expected: &str,
407    ) {
408        let mut buf = sqlx::postgres::PgArgumentBuffer::default();
409        let _ = sqlx::Encode::<sqlx::Postgres>::encode(AggressorSidePg(value), &mut buf);
410        assert_eq!(&buf[..], expected.as_bytes());
411    }
412
413    #[rstest]
414    #[case(BarAggregation::Millisecond, "MILLISECOND")]
415    #[case(BarAggregation::Second, "SECOND")]
416    #[case(BarAggregation::Month, "MONTH")]
417    #[case(BarAggregation::Year, "YEAR")]
418    #[case(BarAggregation::Renko, "RENKO")]
419    fn bar_aggregation_pg_encodes_postgres_labels(
420        #[case] value: BarAggregation,
421        #[case] expected: &str,
422    ) {
423        let mut buf = sqlx::postgres::PgArgumentBuffer::default();
424        let _ = sqlx::Encode::<sqlx::Postgres>::encode(BarAggregationPg(value), &mut buf);
425        assert_eq!(&buf[..], expected.as_bytes());
426        assert_eq!(BarAggregation::from_str(expected), Ok(value));
427    }
428
429    #[rstest]
430    #[case(PriceType::Bid, "BID")]
431    #[case(PriceType::Ask, "ASK")]
432    #[case(PriceType::Mid, "MID")]
433    #[case(PriceType::Last, "LAST")]
434    #[case(PriceType::Mark, "MARK")]
435    fn price_type_pg_encodes_postgres_labels(#[case] value: PriceType, #[case] expected: &str) {
436        let mut buf = sqlx::postgres::PgArgumentBuffer::default();
437        let _ = sqlx::Encode::<sqlx::Postgres>::encode(PriceTypePg(value), &mut buf);
438        assert_eq!(&buf[..], expected.as_bytes());
439        assert_eq!(PriceType::from_str(expected), Ok(value));
440    }
441}