From 724d78fb2dbef343617dcae6e7f31f1175c3cd09 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 20:34:38 +0800 Subject: [PATCH] feat(quote)!: make QuoteShort and AftermarketTrade fields optional This change makes `QuoteShort.price`, `QuoteShort.change`, `QuoteShort.volume`, and `AftermarketTrade.tradeSize` optional across the Rust, Python, and Go SDKs so that a `null` provider value surfaces as `None`/`Option`/pointer instead of a hard decode failure or a coerced default. **Response models:** * `libfmp` `QuoteShort` now wraps `price`, `change`, and `volume` in `Option`, and `AftermarketTrade.tradeSize` in `Option`, with a `required_option` deserializer that still rejects a missing member while accepting an explicit `null`. Volume and trade size re-encode through the `integral_f64::serialize_option` codec. * The PyO3 bindings expose these members as `Optional[float]` in both the constructors and the generated `.pyi` stubs. * The Go `QuoteShort` and `AftermarketTrade` structs use `*float64` for the optional members and decode each through a `jsontext.Value` shadow that distinguishes missing from `null`. **Tests and fixtures:** * Added coverage for `null` `tradeSize`, `change`, and `volume` rows, extending the synthetic aftermarket-trade fixture and the multiple-quote-short fixture with `null` values. * Updated Rust, Python, and Go assertions to compare against `Some(..)`/pointers and to guard for `None` before dereferencing, and added a `quoteShortMatches` helper in the Go parity tests. * Reworked the Go and proxy examples to handle a nil price, change, or volume before printing. BREAKING CHANGE: QuoteShort price, change and volume and AftermarketTrade.tradeSize are now optional (Option / pointer / Optional); callers must handle None. --- .../python/fmp/_native/quote/__init__.pyi | 16 ++--- crates/fmp-py/src/models/quote.rs | 12 ++-- crates/fmp-py/tests/test_quote.py | 11 +++ crates/fmp-py/tests/typecheck_contract.py | 8 +-- crates/libfmp/src/responses/quote.rs | 26 +++++-- .../tests/asset_catalog_quote_endpoints.rs | 6 +- .../tests/asset_catalog_quote_responses.rs | 2 +- .../fixtures/aftermarket_trade_synthetic.json | 6 ++ .../tests/fixtures/quote_short_multiple.json | 4 +- crates/libfmp/tests/indexes_responses.rs | 2 +- crates/libfmp/tests/integral_f64_responses.rs | 4 +- crates/libfmp/tests/quote_batch_endpoints.rs | 4 +- crates/libfmp/tests/quote_responses.rs | 45 +++++++++--- crates/libfmp/tests/quote_short_endpoint.rs | 14 ++-- crates/libfmp/tests/quote_single_endpoints.rs | 4 +- .../libfmp/tests/quote_universe_endpoints.rs | 8 +-- sdk/go/commodities_parity_test.go | 10 ++- sdk/go/commodities_test.go | 2 +- sdk/go/crypto_parity_test.go | 10 ++- sdk/go/crypto_test.go | 2 +- sdk/go/example_client_test.go | 6 +- sdk/go/examples/proxy/main.go | 5 +- sdk/go/forex_parity_test.go | 10 ++- sdk/go/forex_test.go | 2 +- sdk/go/indexes_parity_test.go | 10 ++- sdk/go/indexes_test.go | 2 +- sdk/go/integral_f64_parity_test.go | 4 +- sdk/go/live_test.go | 2 +- sdk/go/quote_models.go | 68 ++++++++++++++----- sdk/go/quote_parity_test.go | 33 +++++---- sdk/go/quote_test.go | 8 +-- 31 files changed, 221 insertions(+), 125 deletions(-) diff --git a/crates/fmp-py/python/fmp/_native/quote/__init__.pyi b/crates/fmp-py/python/fmp/_native/quote/__init__.pyi index a3fe3c1d..74c0b7ca 100644 --- a/crates/fmp-py/python/fmp/_native/quote/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/quote/__init__.pyi @@ -76,7 +76,7 @@ class AftermarketTrade: @property def price(self) -> builtins.float: ... @property - def trade_size(self) -> builtins.float: ... + def trade_size(self) -> typing.Optional[builtins.float]: ... @property def timestamp(self) -> builtins.int: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... @@ -85,7 +85,7 @@ class AftermarketTrade: *, symbol: builtins.str, price: builtins.float, - trade_size: builtins.float, + trade_size: typing.Optional[builtins.float], timestamp: builtins.int, ) -> AftermarketTrade: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... @@ -299,19 +299,19 @@ class QuoteShort: @property def symbol(self) -> builtins.str: ... @property - def price(self) -> builtins.float: ... + def price(self) -> typing.Optional[builtins.float]: ... @property - def change(self) -> builtins.float: ... + def change(self) -> typing.Optional[builtins.float]: ... @property - def volume(self) -> builtins.float: ... + def volume(self) -> typing.Optional[builtins.float]: ... def __eq__(self, other: builtins.object, /) -> builtins.bool: ... def __new__( cls, *, symbol: builtins.str, - price: builtins.float, - change: builtins.float, - volume: builtins.float, + price: typing.Optional[builtins.float], + change: typing.Optional[builtins.float], + volume: typing.Optional[builtins.float], ) -> QuoteShort: ... def __getnewargs_ex__(self) -> tuple[tuple, dict]: ... def __repr__(self) -> builtins.str: ... diff --git a/crates/fmp-py/src/models/quote.rs b/crates/fmp-py/src/models/quote.rs index 5e4ab392..611f1fd4 100644 --- a/crates/fmp-py/src/models/quote.rs +++ b/crates/fmp-py/src/models/quote.rs @@ -311,11 +311,11 @@ pub(crate) struct QuoteShort { #[pyo3(get)] pub symbol: String, #[pyo3(get)] - pub price: f64, + pub price: Option, #[pyo3(get)] - pub change: f64, + pub change: Option, #[pyo3(get)] - pub volume: f64, + pub volume: Option, } #[gen_stub_pymethods] @@ -325,7 +325,7 @@ impl QuoteShort { #[allow(clippy::too_many_arguments)] #[allow(clippy::fn_params_excessive_bools)] #[pyo3(signature = (*, symbol, price, change, volume))] - fn new(symbol: String, price: f64, change: f64, volume: f64) -> Self { + fn new(symbol: String, price: Option, change: Option, volume: Option) -> Self { Self { symbol, price, @@ -419,7 +419,7 @@ pub(crate) struct AftermarketTrade { #[pyo3(get)] pub price: f64, #[pyo3(get)] - pub trade_size: f64, + pub trade_size: Option, #[pyo3(get)] pub timestamp: i64, } @@ -431,7 +431,7 @@ impl AftermarketTrade { #[allow(clippy::too_many_arguments)] #[allow(clippy::fn_params_excessive_bools)] #[pyo3(signature = (*, symbol, price, trade_size, timestamp))] - fn new(symbol: String, price: f64, trade_size: f64, timestamp: i64) -> Self { + fn new(symbol: String, price: f64, trade_size: Option, timestamp: i64) -> Self { Self { symbol, price, diff --git a/crates/fmp-py/tests/test_quote.py b/crates/fmp-py/tests/test_quote.py index 7847f976..842bf297 100644 --- a/crates/fmp-py/tests/test_quote.py +++ b/crates/fmp-py/tests/test_quote.py @@ -44,6 +44,15 @@ def test_aftermarket_trade_takes_one_symbol(client: Any, fixture_server: Fixture assert row.timestamp == AFTERMARKET_TIMESTAMP +def test_aftermarket_trade_null_trade_size_is_none(client: Any, fixture_server: FixtureServer) -> None: + """A ``null`` provider ``tradeSize`` surfaces as ``None``.""" + fixture_server.route("/aftermarket-trade", load_fixture("aftermarket_trade_synthetic.json")) + rows = client.quote.aftermarket_trade("000001.SZ") + + assert len(rows) == 3 + assert rows[2].trade_size is None + + def test_aftermarket_quote_takes_one_symbol(client: Any, fixture_server: FixtureServer) -> None: """``aftermarket_quote`` decodes the bid-and-ask row for one symbol.""" fixture_server.route("/aftermarket-quote", load_fixture("aftermarket_quote.json")) @@ -106,6 +115,8 @@ def test_batch_quote_short_accepts_a_bare_string(client: Any, fixture_server: Fi assert [row.symbol for row in rows] == ["000001.SZ", "^VIX"] assert rows[0].volume == 4_294_967_296 assert rows[1].price == pytest.approx(18.75) + assert rows[1].change is None + assert rows[1].volume is None def test_batch_aftermarket_trade_preserves_symbol_order(client: Any, fixture_server: FixtureServer) -> None: diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 7f7db1d3..9642a0bc 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -245,7 +245,7 @@ def check_forex_contract(client: FmpClient) -> None: quotes: list[Quote] = client.forex.quote("EURUSD") market_cap: float | None = quotes[0].market_cap compact: list[QuoteShort] = client.forex.quote_short("EURUSD") - price: float = compact[0].price + price: float | None = compact[0].price bars: list[StockChartFullBar] = forex.chart_full("EURUSD", from_=datetime.date(2026, 1, 27), to="2026-04-27") bar_date: datetime.date = bars[0].date vwap: float = bars[0].vwap @@ -629,11 +629,11 @@ def check_public_contract(client: FmpClient) -> None: trades: list[AftermarketTrade] = client.quote.batch_aftermarket_trade("AAPL") changes: list[StockPriceChange] = client.quote.stock_price_change("AAPL") exchange_rows: list[QuoteShort] = client.quote.exchange("NASDAQ") - trade_size: float = trades[0].trade_size + trade_size: float | None = trades[0].trade_size ten_years: float = changes[0].ten_years symbol: str = short_rows[0].symbol price: float = full_rows[0].price - volume: float = fund_rows[0].volume + volume: float | None = fund_rows[0].volume market_cap: float | None = full_rows[0].market_cap exports: list[str] = fmp.__all__ version: str = fmp.__version__ @@ -689,7 +689,7 @@ def check_model_protocol_contract(quote: QuoteShort, report: CotReport) -> None: text: str = repr(quote) match quote: case QuoteShort(symbol, price): - matched: tuple[str, float] = (symbol, price) + matched: tuple[str, float | None] = (symbol, price) _ = matched share: int | float = report.pct_of_open_interest_all _ = (same, as_dict, names, text, share) diff --git a/crates/libfmp/src/responses/quote.rs b/crates/libfmp/src/responses/quote.rs index 437eec24..9e3aec3c 100644 --- a/crates/libfmp/src/responses/quote.rs +++ b/crates/libfmp/src/responses/quote.rs @@ -1,12 +1,20 @@ //! Response models returned by quote endpoints. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use crate::types::{ Change, ExchangeCode, MarketCapitalization, Percentage, Price, Quantity, Ticker, UnixMilliseconds, UnixSeconds, Volume, }; +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + /// A detailed real-time stock quote. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[non_exhaustive] @@ -39,10 +47,13 @@ pub struct Quote { #[serde(rename_all = "camelCase")] pub struct QuoteShort { pub symbol: Ticker, - pub price: Price, - pub change: Change, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub volume: Volume, + #[serde(deserialize_with = "required_option")] + pub price: Option, + #[serde(deserialize_with = "required_option")] + pub change: Option, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub volume: Option, } /// One trade executed after regular US market hours. @@ -52,8 +63,9 @@ pub struct QuoteShort { pub struct AftermarketTrade { pub symbol: Ticker, pub price: Price, - #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] - pub trade_size: Quantity, + #[serde(deserialize_with = "required_option")] + #[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")] + pub trade_size: Option, pub timestamp: UnixMilliseconds, } diff --git a/crates/libfmp/tests/asset_catalog_quote_endpoints.rs b/crates/libfmp/tests/asset_catalog_quote_endpoints.rs index 63b20ee7..b4319b6a 100644 --- a/crates/libfmp/tests/asset_catalog_quote_endpoints.rs +++ b/crates/libfmp/tests/asset_catalog_quote_endpoints.rs @@ -187,11 +187,11 @@ async fn proxy_routing_uses_all_asset_facades_custom_auth_default_headers_and_ex assert_eq!(commodity_batch[0].symbol.as_str(), "DCUSD"); assert_eq!(pairs[0].from_currency.as_str(), "ARS"); assert_eq!(forex[0].market_cap, None); - assert_eq!(forex_short[0].volume, 146_872.0); - assert_eq!(forex_batch[0].change, -0.00513532); + assert_eq!(forex_short[0].volume, Some(146_872.0)); + assert_eq!(forex_batch[0].change, Some(-0.00513532)); assert_eq!(cryptocurrencies[0].total_supply, 4_788_606_639.0); assert_eq!(crypto[0].market_cap, Some(1_293_361_815_015.0)); - assert_eq!(crypto_short[0].volume, 32_030_003_200.0); + assert_eq!(crypto_short[0].volume, Some(32_030_003_200.0)); assert_eq!(crypto_batch[0].symbol.as_str(), "00USD"); let requests = executor.requests(); diff --git a/crates/libfmp/tests/asset_catalog_quote_responses.rs b/crates/libfmp/tests/asset_catalog_quote_responses.rs index 491e9207..1025ee96 100644 --- a/crates/libfmp/tests/asset_catalog_quote_responses.rs +++ b/crates/libfmp/tests/asset_catalog_quote_responses.rs @@ -241,7 +241,7 @@ fn crypto_quotes_preserve_large_volume_market_cap_and_nullable_market_cap() { let compact: Vec = serde_json::from_slice(CRYPTOCURRENCY_QUOTE_SHORT).unwrap(); - assert_eq!(compact[0].volume, 32_030_003_200.0); + assert_eq!(compact[0].volume, Some(32_030_003_200.0)); } #[test] diff --git a/crates/libfmp/tests/fixtures/aftermarket_trade_synthetic.json b/crates/libfmp/tests/fixtures/aftermarket_trade_synthetic.json index ec677179..547b011f 100644 --- a/crates/libfmp/tests/fixtures/aftermarket_trade_synthetic.json +++ b/crates/libfmp/tests/fixtures/aftermarket_trade_synthetic.json @@ -13,5 +13,11 @@ "symbol": "000001.SZ", "timestamp": 1700000000000, "tradeSize": 9007199254740993 + }, + { + "price": 12.75, + "symbol": "000001.SZ", + "timestamp": 1700000000001, + "tradeSize": null } ] diff --git a/crates/libfmp/tests/fixtures/quote_short_multiple.json b/crates/libfmp/tests/fixtures/quote_short_multiple.json index 05aa5005..e7c00f6e 100644 --- a/crates/libfmp/tests/fixtures/quote_short_multiple.json +++ b/crates/libfmp/tests/fixtures/quote_short_multiple.json @@ -6,9 +6,9 @@ "volume": 4294967296 }, { - "change": 0.5, + "change": null, "price": 18.75, "symbol": "^VIX", - "volume": 0 + "volume": null } ] diff --git a/crates/libfmp/tests/indexes_responses.rs b/crates/libfmp/tests/indexes_responses.rs index 72adc260..1bfa8f4f 100644 --- a/crates/libfmp/tests/indexes_responses.rs +++ b/crates/libfmp/tests/indexes_responses.rs @@ -30,7 +30,7 @@ fn every_documented_index_directory_and_quote_fixture_matches_the_typed_wire_con let compact: Vec = serde_json::from_slice(QUOTE_SHORT).unwrap(); assert_eq!(compact[0].symbol.as_str(), "^VIX"); - assert_eq!(compact[0].volume, 0.0); + assert_eq!(compact[0].volume, Some(0.0)); } fn assert_exact(fixture: &[u8]) diff --git a/crates/libfmp/tests/integral_f64_responses.rs b/crates/libfmp/tests/integral_f64_responses.rs index adf49afb..641a7f19 100644 --- a/crates/libfmp/tests/integral_f64_responses.rs +++ b/crates/libfmp/tests/integral_f64_responses.rs @@ -171,7 +171,7 @@ fn statement_amounts_decode_fractional_integral_float_and_exponent_forms() { fn aftermarket_trade_size_decodes_a_fractional_quantity() { let rows: Vec = serde_json::from_slice(TRADES).unwrap(); - assert_eq!(rows[0].trade_size, 16.5); + assert_eq!(rows[0].trade_size, Some(16.5)); let wire = serde_json::to_value(&rows).unwrap(); assert_eq!(wire[0]["tradeSize"], json!(16.5)); } @@ -222,7 +222,7 @@ fn exponent_form_numbers_decode_into_every_integral_f64_alias() { r#"{"symbol":"AAPL","price":232.53,"tradeSize":1.6e1,"timestamp":1738715334311}"#, ) .unwrap(); - assert_eq!(trade.trade_size, 16.0); + assert_eq!(trade.trade_size, Some(16.0)); assert_eq!( serde_json::to_value(&trade).unwrap()["tradeSize"], json!(16_u64) diff --git a/crates/libfmp/tests/quote_batch_endpoints.rs b/crates/libfmp/tests/quote_batch_endpoints.rs index 4f3a46a8..182118c7 100644 --- a/crates/libfmp/tests/quote_batch_endpoints.rs +++ b/crates/libfmp/tests/quote_batch_endpoints.rs @@ -110,8 +110,8 @@ async fn proxy_client_preserves_symbol_order_comma_encoding_and_decodes_every_sh let aftermarket_quotes = client.batch_aftermarket_quote(&symbols).await.unwrap(); assert_eq!(quotes[0].name, "Apple Inc."); - assert_eq!(short_quotes[0].volume, 28_718_014.0); - assert_eq!(trades[0].trade_size, 16.0); + assert_eq!(short_quotes[0].volume, Some(28_718_014.0)); + assert_eq!(trades[0].trade_size, Some(16.0)); assert_eq!( aftermarket_quotes[0].timestamp, UnixMilliseconds(1_785_430_813_000) diff --git a/crates/libfmp/tests/quote_responses.rs b/crates/libfmp/tests/quote_responses.rs index 894b1e4d..2d093fa8 100644 --- a/crates/libfmp/tests/quote_responses.rs +++ b/crates/libfmp/tests/quote_responses.rs @@ -54,9 +54,9 @@ fn documented_short_quote_preserves_negative_change() { assert_eq!(quotes.len(), 1); assert_eq!(quotes[0].symbol.as_str(), "AAPL"); - assert_eq!(quotes[0].price, 331.85501); - assert_eq!(quotes[0].change, -6.33498); - assert_eq!(quotes[0].volume, 28_718_014.0); + assert_eq!(quotes[0].price, Some(331.85501)); + assert_eq!(quotes[0].change, Some(-6.33498)); + assert_eq!(quotes[0].volume, Some(28_718_014.0)); } #[test] @@ -69,8 +69,29 @@ fn bare_quote_arrays_retain_empty_and_multiple_shapes() { assert!(empty.is_empty()); assert_eq!(multiple.len(), 2); assert_eq!(multiple[0].symbol.as_str(), "000001.SZ"); - assert_eq!(multiple[0].volume, 4_294_967_296.0); + assert_eq!(multiple[0].volume, Some(4_294_967_296.0)); assert_eq!(multiple[1].symbol.as_str(), "^VIX"); + assert_eq!(multiple[1].price, Some(18.75)); + assert_eq!(multiple[1].change, None); + assert_eq!(multiple[1].volume, None); +} + +#[test] +fn short_quote_null_members_decode_to_none_and_re_encode_as_null() { + let quote: QuoteShort = + serde_json::from_str(r#"{"symbol":"VFIAX","price":null,"change":null,"volume":null}"#) + .unwrap(); + + assert_eq!( + (quote.price, quote.change, quote.volume), + (None, None, None) + ); + let wire = serde_json::to_value("e).unwrap(); + assert!(wire["price"].is_null() && wire["change"].is_null() && wire["volume"].is_null()); + assert!( + serde_json::from_str::(r#"{"symbol":"VFIAX","price":1.0,"change":0.0}"#) + .is_err() + ); } #[test] @@ -82,7 +103,7 @@ fn documented_aftermarket_trade_decodes_exact_wire_values_in_milliseconds() { let trade = &trades[0]; assert_eq!(trade.symbol, Ticker::new("AAPL").unwrap()); assert_eq!(trade.price, 331.85999); - assert_eq!(trade.trade_size, 16.0); + assert_eq!(trade.trade_size, Some(16.0)); assert_eq!(trade.timestamp, UnixMilliseconds(1_785_430_813_000)); let wire = serde_json::to_value(trade).unwrap(); @@ -165,10 +186,18 @@ fn new_quote_response_arrays_preserve_empty_multiple_unknown_and_large_values() let trades: Vec = serde_json::from_str(include_str!("fixtures/aftermarket_trade_synthetic.json")).unwrap(); - assert_eq!(trades.len(), 2); - assert_eq!(trades[0].trade_size, u64::MAX as f64); + assert_eq!(trades.len(), 3); + assert_eq!(trades[0].trade_size, Some(u64::MAX as f64)); assert_eq!(trades[0].timestamp, UnixMilliseconds(i64::MAX)); - assert_eq!(trades[1].trade_size, 9_007_199_254_740_992.0); + assert_eq!(trades[1].trade_size, Some(9_007_199_254_740_992.0)); + assert_eq!(trades[2].trade_size, None); + assert!(serde_json::to_value(&trades[2]).unwrap()["tradeSize"].is_null()); + assert!( + serde_json::from_str::( + r#"{"symbol":"AAPL","price":1.0,"timestamp":1700000000001}"# + ) + .is_err() + ); let quotes: Vec = serde_json::from_str(include_str!("fixtures/aftermarket_quote_synthetic.json")).unwrap(); diff --git a/crates/libfmp/tests/quote_short_endpoint.rs b/crates/libfmp/tests/quote_short_endpoint.rs index 4a5604cb..177fa9fe 100644 --- a/crates/libfmp/tests/quote_short_endpoint.rs +++ b/crates/libfmp/tests/quote_short_endpoint.rs @@ -40,9 +40,9 @@ async fn client_decodes_every_documented_field_without_selecting_a_first_row() { assert_eq!(rows.len(), 1); assert_eq!(rows[0].symbol.as_str(), "AAPL"); - assert_eq!(rows[0].price, 331.85501); - assert_eq!(rows[0].change, -6.33498); - assert_eq!(rows[0].volume, 28_718_014.0); + assert_eq!(rows[0].price, Some(331.85501)); + assert_eq!(rows[0].change, Some(-6.33498)); + assert_eq!(rows[0].volume, Some(28_718_014.0)); let requests = executor.requests(); assert_eq!(requests.len(), 1); @@ -70,7 +70,7 @@ async fn client_preserves_empty_multiple_and_forward_compatible_arrays() { assert!(empty.is_empty()); assert_eq!(multiple.len(), 2); assert_eq!(multiple[0].symbol.as_str(), "000001.SZ"); - assert_eq!(multiple[0].volume, 4_294_967_296.0); + assert_eq!(multiple[0].volume, Some(4_294_967_296.0)); assert_eq!(multiple[1].symbol.as_str(), "^VIX"); assert_eq!(future_shape.len(), 1); assert_eq!(future_shape[0].symbol.as_str(), "AAPL"); @@ -89,9 +89,9 @@ async fn client_decodes_the_fractional_volume_observed_live_and_re_encodes_it_un .unwrap(); assert_eq!(rows.len(), 1); - assert_eq!(rows[0].price, 342.395); - assert_eq!(rows[0].change, 3.415); - assert_eq!(rows[0].volume, 20_201_922.827_33); + assert_eq!(rows[0].price, Some(342.395)); + assert_eq!(rows[0].change, Some(3.415)); + assert_eq!(rows[0].volume, Some(20_201_922.827_33)); let wire: serde_json::Value = serde_json::from_slice(QUOTE_SHORT_FRACTIONAL_VOLUME).unwrap(); assert_eq!(serde_json::to_value(&rows).unwrap(), wire); } diff --git a/crates/libfmp/tests/quote_single_endpoints.rs b/crates/libfmp/tests/quote_single_endpoints.rs index 8990f614..3ea9c627 100644 --- a/crates/libfmp/tests/quote_single_endpoints.rs +++ b/crates/libfmp/tests/quote_single_endpoints.rs @@ -127,8 +127,8 @@ async fn proxy_client_uses_exact_get_paths_and_required_symbol_encoding() { let changes = client.stock_price_change(&symbol).await.unwrap(); assert_eq!(quotes[0].name, "Apple Inc."); - assert_eq!(short_quotes[0].volume, 28_718_014.0); - assert_eq!(trades[0].trade_size, 16.0); + assert_eq!(short_quotes[0].volume, Some(28_718_014.0)); + assert_eq!(trades[0].trade_size, Some(16.0)); assert_eq!( aftermarket_quotes[0].timestamp, UnixMilliseconds(1_785_430_813_000) diff --git a/crates/libfmp/tests/quote_universe_endpoints.rs b/crates/libfmp/tests/quote_universe_endpoints.rs index 540137c1..7316f08f 100644 --- a/crates/libfmp/tests/quote_universe_endpoints.rs +++ b/crates/libfmp/tests/quote_universe_endpoints.rs @@ -144,11 +144,11 @@ async fn proxy_client_uses_every_exact_path_and_decodes_documented_short_rows() let indexes = client.index_quotes().await.unwrap(); assert_eq!(exchange[0].symbol.as_str(), "AAACX"); - assert_eq!(mutual_funds[0].price, 44.0); - assert_eq!(etfs[0].volume, 1.0); - assert_eq!(commodities[0].change, 0.02); + assert_eq!(mutual_funds[0].price, Some(44.0)); + assert_eq!(etfs[0].volume, Some(1.0)); + assert_eq!(commodities[0].change, Some(0.02)); assert_eq!(cryptocurrencies[0].symbol.as_str(), "00USD"); - assert_eq!(forex[0].price, 0.38716); + assert_eq!(forex[0].price, Some(0.38716)); assert_eq!(indexes[0].symbol.as_str(), "^SPROME10"); let requests = executor.requests(); diff --git a/sdk/go/commodities_parity_test.go b/sdk/go/commodities_parity_test.go index 78efdcc0..c69d3099 100644 --- a/sdk/go/commodities_parity_test.go +++ b/sdk/go/commodities_parity_test.go @@ -53,14 +53,12 @@ func TestDocumentedCommodityCatalogAndQuoteFixturesDecodeExactValues(t *testing. } short := assertFixtureParity[QuoteShort](t, "commodities_quote_short.json") - if want := (QuoteShort{Symbol: "GCUSD", Price: 4168.3, Change: 132, Volume: 125_925}); len(short) != 1 || - short[0] != want { - t.Fatalf("commodities_quote_short = %+v, want %+v", short, want) + if len(short) != 1 || !quoteShortMatches(short[0], "GCUSD", 4168.3, 132, 125_925) { + t.Fatalf("commodities_quote_short = %+v", short) } batch := assertFixtureParity[QuoteShort](t, "commodities_quotes.json") - if want := (QuoteShort{Symbol: "DCUSD", Price: 16.91, Change: 0.02, Volume: 615}); len(batch) != 1 || - batch[0] != want { - t.Fatalf("commodities_quotes = %+v, want %+v", batch, want) + if len(batch) != 1 || !quoteShortMatches(batch[0], "DCUSD", 16.91, 0.02, 615) { + t.Fatalf("commodities_quotes = %+v", batch) } } diff --git a/sdk/go/commodities_test.go b/sdk/go/commodities_test.go index 05862aaf..0c4c82f6 100644 --- a/sdk/go/commodities_test.go +++ b/sdk/go/commodities_test.go @@ -59,7 +59,7 @@ func TestCommoditiesMethodsUseExactPathsAndQueryOrder(t *testing.T) { t.Fatalf("Quote = %+v, %v", quote, err) } short, err := client.Commodities.QuoteShort(ctx, NewQuoteShortQuery("GCUSD")) - if err != nil || len(short) != 1 || short[0].Symbol != "GCUSD" || short[0].Volume != 125_925 { + if err != nil || len(short) != 1 || short[0].Symbol != "GCUSD" || short[0].Volume == nil || *short[0].Volume != 125_925 { t.Fatalf("QuoteShort = %+v, %v", short, err) } diff --git a/sdk/go/crypto_parity_test.go b/sdk/go/crypto_parity_test.go index 3a7866ae..d82a445e 100644 --- a/sdk/go/crypto_parity_test.go +++ b/sdk/go/crypto_parity_test.go @@ -53,14 +53,12 @@ func TestCryptoCatalogAndQuoteFixturesDecodeExactValues(t *testing.T) { } short := assertFixtureParity[QuoteShort](t, "cryptocurrency_quote_short.json") - if want := (QuoteShort{Symbol: "BTCUSD", Price: 64756.84, Change: 853.94, Volume: 32_030_003_200}); len(short) != 1 || - short[0] != want { - t.Fatalf("cryptocurrency_quote_short = %+v, want %+v", short, want) + if len(short) != 1 || !quoteShortMatches(short[0], "BTCUSD", 64756.84, 853.94, 32_030_003_200) { + t.Fatalf("cryptocurrency_quote_short = %+v", short) } batch := assertFixtureParity[QuoteShort](t, "cryptocurrency_quotes.json") - if want := (QuoteShort{Symbol: "00USD", Price: 0.0102, Change: 0.000441090368, Volume: 329_571}); len(batch) != 1 || - batch[0] != want { - t.Fatalf("cryptocurrency_quotes = %+v, want %+v", batch, want) + if len(batch) != 1 || !quoteShortMatches(batch[0], "00USD", 0.0102, 0.000441090368, 329_571) { + t.Fatalf("cryptocurrency_quotes = %+v", batch) } } diff --git a/sdk/go/crypto_test.go b/sdk/go/crypto_test.go index a474208c..e14cd829 100644 --- a/sdk/go/crypto_test.go +++ b/sdk/go/crypto_test.go @@ -60,7 +60,7 @@ func TestCryptoMethodsUseExactPathsAndQueryOrder(t *testing.T) { t.Fatalf("Quote = %+v, %v", quote, err) } short, err := client.Crypto.QuoteShort(ctx, NewQuoteShortQuery("BTCUSD")) - if err != nil || len(short) != 1 || short[0].Volume != 32_030_003_200 { + if err != nil || len(short) != 1 || short[0].Volume == nil || *short[0].Volume != 32_030_003_200 { t.Fatalf("QuoteShort = %+v, %v", short, err) } light, err := client.Crypto.ChartLight(ctx, NewAssetChartQuery("BTCUSD").WithFrom(eodFrom).WithTo(eodTo)) diff --git a/sdk/go/example_client_test.go b/sdk/go/example_client_test.go index 37612a08..ccf643e8 100644 --- a/sdk/go/example_client_test.go +++ b/sdk/go/example_client_test.go @@ -49,7 +49,11 @@ func printQuoteShort(quotes []fmp.QuoteShort) { return } q := quotes[0] - fmt.Printf("%s price %.5f change %.5f volume %.0f\n", q.Symbol, q.Price, q.Change, q.Volume) + if q.Price == nil || q.Change == nil || q.Volume == nil { + fmt.Printf("%s has no price, change or volume\n", q.Symbol) + return + } + fmt.Printf("%s price %.5f change %.5f volume %.0f\n", q.Symbol, *q.Price, *q.Change, *q.Volume) } // ExampleNewClient builds a client against an in-process server. Drop diff --git a/sdk/go/examples/proxy/main.go b/sdk/go/examples/proxy/main.go index da34d7ce..eba2e3ec 100644 --- a/sdk/go/examples/proxy/main.go +++ b/sdk/go/examples/proxy/main.go @@ -82,8 +82,11 @@ func run() error { } quote := quotes[0] + if quote.Price == nil || quote.Change == nil || quote.Volume == nil { + return fmt.Errorf("quote %s has no price, change or volume", quote.Symbol) + } fmt.Printf("client: %v\n", client) fmt.Printf("%s via tenant %s: price %.5f, change %.5f, volume %.0f\n", - quote.Symbol, tenant, quote.Price, quote.Change, quote.Volume) + quote.Symbol, tenant, *quote.Price, *quote.Change, *quote.Volume) return nil } diff --git a/sdk/go/forex_parity_test.go b/sdk/go/forex_parity_test.go index f70a4287..83e860fc 100644 --- a/sdk/go/forex_parity_test.go +++ b/sdk/go/forex_parity_test.go @@ -59,14 +59,12 @@ func TestDocumentedForexCatalogAndQuoteFixturesDecodeExactValues(t *testing.T) { } short := assertFixtureParity[QuoteShort](t, "forex_quote_short.json") - if want := (QuoteShort{Symbol: "EURUSD", Price: 1.15284, Change: 0.006314, Volume: 146_872}); len(short) != 1 || - short[0] != want { - t.Fatalf("forex_quote_short = %+v, want %+v", short, want) + if len(short) != 1 || !quoteShortMatches(short[0], "EURUSD", 1.15284, 0.006314, 146_872) { + t.Fatalf("forex_quote_short = %+v", short) } batch := assertFixtureParity[QuoteShort](t, "forex_quotes.json") - if want := (QuoteShort{Symbol: "AEDAUD", Price: 0.38716, Change: -0.00513532, Volume: 0}); len(batch) != 1 || - batch[0] != want { - t.Fatalf("forex_quotes = %+v, want %+v", batch, want) + if len(batch) != 1 || !quoteShortMatches(batch[0], "AEDAUD", 0.38716, -0.00513532, 0) { + t.Fatalf("forex_quotes = %+v", batch) } } diff --git a/sdk/go/forex_test.go b/sdk/go/forex_test.go index d099f831..085d6a95 100644 --- a/sdk/go/forex_test.go +++ b/sdk/go/forex_test.go @@ -57,7 +57,7 @@ func TestForexMethodsUseExactPathsAndWireOrder(t *testing.T) { t.Fatalf("Quote = %+v, %v", quotes, err) } short, err := client.Forex.QuoteShort(ctx, NewQuoteShortQuery("EURUSD")) - if err != nil || len(short) != 1 || short[0].Volume != 146_872 { + if err != nil || len(short) != 1 || short[0].Volume == nil || *short[0].Volume != 146_872 { t.Fatalf("QuoteShort = %+v, %v", short, err) } light, err := client.Forex.ChartLight(ctx, NewAssetChartQuery("EURUSD").WithFrom(eodFrom).WithTo(eodTo)) diff --git a/sdk/go/indexes_parity_test.go b/sdk/go/indexes_parity_test.go index b2b5050a..81534f24 100644 --- a/sdk/go/indexes_parity_test.go +++ b/sdk/go/indexes_parity_test.go @@ -59,14 +59,12 @@ func TestDocumentedIndexDirectoryAndQuoteFixturesDecodeExactValues(t *testing.T) } short := assertFixtureParity[QuoteShort](t, "indexes_quote_short.json") - if want := (QuoteShort{Symbol: "^VIX", Price: 18.1, Change: -2.56, Volume: 0}); len(short) != 1 || - short[0] != want { - t.Fatalf("indexes_quote_short = %+v, want %+v", short, want) + if len(short) != 1 || !quoteShortMatches(short[0], "^VIX", 18.1, -2.56, 0) { + t.Fatalf("indexes_quote_short = %+v", short) } batch := assertFixtureParity[QuoteShort](t, "indexes_quotes.json") - if want := (QuoteShort{Symbol: "^SPROME10", Price: 4520.37, Change: 44.67, Volume: 0}); len(batch) != 1 || - batch[0] != want { - t.Fatalf("indexes_quotes = %+v, want %+v", batch, want) + if len(batch) != 1 || !quoteShortMatches(batch[0], "^SPROME10", 4520.37, 44.67, 0) { + t.Fatalf("indexes_quotes = %+v", batch) } } diff --git a/sdk/go/indexes_test.go b/sdk/go/indexes_test.go index 3e967e58..9dd2bc33 100644 --- a/sdk/go/indexes_test.go +++ b/sdk/go/indexes_test.go @@ -67,7 +67,7 @@ func TestIndexesMethodsUseExactPathsAndQueryOrder(t *testing.T) { t.Fatalf("Quote = %+v, %v", quote, err) } short, err := client.Indexes.QuoteShort(ctx, NewQuoteShortQuery("^VIX")) - if err != nil || len(short) != 1 || short[0].Symbol != "^VIX" || short[0].Price != 18.1 { + if err != nil || len(short) != 1 || short[0].Symbol != "^VIX" || short[0].Price == nil || *short[0].Price != 18.1 { t.Fatalf("QuoteShort = %+v, %v", short, err) } diff --git a/sdk/go/integral_f64_parity_test.go b/sdk/go/integral_f64_parity_test.go index 2941a443..3d94f196 100644 --- a/sdk/go/integral_f64_parity_test.go +++ b/sdk/go/integral_f64_parity_test.go @@ -92,7 +92,7 @@ func TestQuantityAndAmountFieldsDecodeFractionalForms(t *testing.T) { } trades := assertFixtureParity[AftermarketTrade](t, "aftermarket_trade_fractional_synthetic.json") - if len(trades) != 1 || trades[0].TradeSize != 16.5 { + if len(trades) != 1 || trades[0].TradeSize == nil || *trades[0].TradeSize != 16.5 { t.Fatalf("aftermarket_trade_fractional_synthetic = %+v", trades) } @@ -107,7 +107,7 @@ func TestQuantityAndAmountFieldsDecodeExponentForms(t *testing.T) { var trade AftermarketTrade err := json.Unmarshal([]byte(`{"symbol":"AAPL","price":232.53,"tradeSize":1.6e1,"timestamp":1738715334311}`), &trade) - if err != nil || trade.TradeSize != 16 { + if err != nil || trade.TradeSize == nil || *trade.TradeSize != 16 { t.Fatalf("AftermarketTrade = %+v, %v", trade, err) } diff --git a/sdk/go/live_test.go b/sdk/go/live_test.go index fc534b0a..d0d96967 100644 --- a/sdk/go/live_test.go +++ b/sdk/go/live_test.go @@ -112,7 +112,7 @@ func liveAssertAppleRows(t *testing.T, rows []QuoteShort) { if rows[0].Symbol != liveSymbol { t.Fatalf("quote-short symbol = %q, want %q", rows[0].Symbol, liveSymbol) } - if rows[0].Price <= 0 { + if rows[0].Price == nil || *rows[0].Price <= 0 { t.Fatalf("quote-short price = %v, want a positive price", rows[0].Price) } } diff --git a/sdk/go/quote_models.go b/sdk/go/quote_models.go index bfdd6238..3334c8f6 100644 --- a/sdk/go/quote_models.go +++ b/sdk/go/quote_models.go @@ -120,19 +120,19 @@ func (m *Quote) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // QuoteShort is the compact response returned by quote-short endpoints across // asset classes. type QuoteShort struct { - Symbol string `json:"symbol"` - Price float64 `json:"price"` - Change float64 `json:"change"` - Volume float64 `json:"volume"` + Symbol string `json:"symbol"` + Price *float64 `json:"price"` + Change *float64 `json:"change"` + Volume *float64 `json:"volume"` } // quoteShortShadow mirrors QuoteShort with a pointer or raw value for every // required member so a missing or null member is observable after decoding. type quoteShortShadow struct { - Symbol *string `json:"symbol"` - Price *float64 `json:"price"` - Change *float64 `json:"change"` - Volume *float64 `json:"volume"` + Symbol *string `json:"symbol"` + Price jsontext.Value `json:"price"` + Change jsontext.Value `json:"change"` + Volume jsontext.Value `json:"volume"` } // UnmarshalJSONFrom decodes one JSON object and rejects it with a Decode @@ -146,18 +146,42 @@ func (m *QuoteShort) UnmarshalJSONFrom(dec *jsontext.Decoder) error { switch { case shadow.Symbol == nil: return missingMemberError("QuoteShort", "symbol") - case shadow.Price == nil: + case len(shadow.Price) == 0: return missingMemberError("QuoteShort", "price") - case shadow.Change == nil: + case len(shadow.Change) == 0: return missingMemberError("QuoteShort", "change") - case shadow.Volume == nil: + case len(shadow.Volume) == 0: return missingMemberError("QuoteShort", "volume") } + var price *float64 + if shadow.Price.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Price, &value); err != nil { + return err + } + price = &value + } + var change *float64 + if shadow.Change.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Change, &value); err != nil { + return err + } + change = &value + } + var volume *float64 + if shadow.Volume.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.Volume, &value); err != nil { + return err + } + volume = &value + } *m = QuoteShort{ Symbol: *shadow.Symbol, - Price: *shadow.Price, - Change: *shadow.Change, - Volume: *shadow.Volume, + Price: price, + Change: change, + Volume: volume, } return nil } @@ -166,7 +190,7 @@ func (m *QuoteShort) UnmarshalJSONFrom(dec *jsontext.Decoder) error { type AftermarketTrade struct { Symbol string `json:"symbol"` Price float64 `json:"price"` - TradeSize float64 `json:"tradeSize"` + TradeSize *float64 `json:"tradeSize"` Timestamp UnixMilliseconds `json:"timestamp"` } @@ -176,7 +200,7 @@ type AftermarketTrade struct { type aftermarketTradeShadow struct { Symbol *string `json:"symbol"` Price *float64 `json:"price"` - TradeSize *float64 `json:"tradeSize"` + TradeSize jsontext.Value `json:"tradeSize"` Timestamp *UnixMilliseconds `json:"timestamp"` } @@ -193,15 +217,23 @@ func (m *AftermarketTrade) UnmarshalJSONFrom(dec *jsontext.Decoder) error { return missingMemberError("AftermarketTrade", "symbol") case shadow.Price == nil: return missingMemberError("AftermarketTrade", "price") - case shadow.TradeSize == nil: + case len(shadow.TradeSize) == 0: return missingMemberError("AftermarketTrade", "tradeSize") case shadow.Timestamp == nil: return missingMemberError("AftermarketTrade", "timestamp") } + var tradeSize *float64 + if shadow.TradeSize.Kind() != 'n' { + var value float64 + if err := json.Unmarshal(shadow.TradeSize, &value); err != nil { + return err + } + tradeSize = &value + } *m = AftermarketTrade{ Symbol: *shadow.Symbol, Price: *shadow.Price, - TradeSize: *shadow.TradeSize, + TradeSize: tradeSize, Timestamp: *shadow.Timestamp, } return nil diff --git a/sdk/go/quote_parity_test.go b/sdk/go/quote_parity_test.go index 1e31d5ce..0d0d0574 100644 --- a/sdk/go/quote_parity_test.go +++ b/sdk/go/quote_parity_test.go @@ -20,7 +20,10 @@ func TestQuoteFixturesDecodeAndReencodeToTheSameMemberSet(t *testing.T) { assertFixtureParity[AftermarketQuote](t, "aftermarket_quote.json") assertFixtureParity[AftermarketQuote](t, "aftermarket_quote_synthetic.json", "futureField") assertFixtureParity[AftermarketTrade](t, "aftermarket_trade.json") - assertFixtureParity[AftermarketTrade](t, "aftermarket_trade_synthetic.json", "futureField") + if trades := assertFixtureParity[AftermarketTrade](t, "aftermarket_trade_synthetic.json", "futureField"); len(trades) != 3 || + trades[2].TradeSize != nil { + t.Fatalf("aftermarket_trade_synthetic = %+v, want a nil tradeSize on the null row", trades) + } assertFixtureParity[StockPriceChange](t, "stock_price_change.json") assertFixtureParity[StockPriceChange](t, "stock_price_change_synthetic.json", "futureField") assertFixtureParity[QuoteShort](t, "quote_exchange_short.json") @@ -36,8 +39,8 @@ func TestQuoteFixturesDecodeAndReencodeToTheSameMemberSet(t *testing.T) { func TestDocumentedAftermarketAndPriceChangeFixturesDecodeExactValues(t *testing.T) { t.Parallel() trades := assertFixtureParity[AftermarketTrade](t, "aftermarket_trade.json") - if want := (AftermarketTrade{Symbol: "AAPL", Price: 331.85999, TradeSize: 16, - Timestamp: UnixMilliseconds(1_785_430_813_000)}); len(trades) != 1 || trades[0] != want { + if len(trades) != 1 || trades[0].Symbol != "AAPL" || trades[0].Price != 331.85999 || trades[0].TradeSize == nil || + *trades[0].TradeSize != 16 || trades[0].Timestamp != UnixMilliseconds(1_785_430_813_000) { t.Fatalf("aftermarket_trade = %+v", trades) } if got := trades[0].Timestamp.Time().UnixMilli(); got != 1_785_430_813_000 { @@ -94,29 +97,33 @@ func TestDocumentedCommodityQuoteAcceptsNullMarketCap(t *testing.T) { } } +func quoteShortMatches(got QuoteShort, symbol string, price, change, volume float64) bool { + return got.Symbol == symbol && got.Price != nil && *got.Price == price && got.Change != nil && + *got.Change == change && got.Volume != nil && *got.Volume == volume +} + func TestShortQuoteFixturesPreserveDocumentedValues(t *testing.T) { t.Parallel() short := assertFixtureParity[QuoteShort](t, "quote_short.json") - if want := (QuoteShort{Symbol: "AAPL", Price: 331.85501, Change: -6.33498, Volume: 28_718_014}); len(short) != 1 || - short[0] != want { + if len(short) != 1 || !quoteShortMatches(short[0], "AAPL", 331.85501, -6.33498, 28_718_014) { t.Fatalf("quote_short = %+v", short) } if empty := assertFixtureParity[QuoteShort](t, "quote_short_empty.json"); empty == nil || len(empty) != 0 { t.Fatalf("quote_short_empty = %#v, want a non-nil empty slice", empty) } multiple := assertFixtureParity[QuoteShort](t, "quote_short_multiple.json") - if len(multiple) != 2 || multiple[0].Symbol != "000001.SZ" || multiple[0].Volume != 4_294_967_296 || - multiple[1].Symbol != "^VIX" || multiple[1].Volume != 0 { + if len(multiple) != 2 || !quoteShortMatches(multiple[0], "000001.SZ", 11.25, -0.15, 4_294_967_296) || + multiple[1].Symbol != "^VIX" || multiple[1].Price == nil || *multiple[1].Price != 18.75 || + multiple[1].Change != nil || multiple[1].Volume != nil { t.Fatalf("quote_short_multiple = %+v", multiple) } unknown := assertFixtureParity[QuoteShort](t, "quote_short_unknown.json", "futureProviderField") - if len(unknown) != 1 || unknown[0] != short[0] { - t.Fatalf("quote_short_unknown = %+v, want %+v", unknown, short) + if len(unknown) != 1 || !quoteShortMatches(unknown[0], "AAPL", 331.85501, -6.33498, 28_718_014) { + t.Fatalf("quote_short_unknown = %+v", unknown) } fractional := assertFixtureParity[QuoteShort](t, "quote_short_fractional_volume.json") - if want := (QuoteShort{Symbol: "AAPL", Price: 342.395, Change: 3.415, Volume: 20_201_922.82733}); len(fractional) != 1 || - fractional[0] != want { - t.Fatalf("quote_short_fractional_volume = %+v, want %+v", fractional, want) + if len(fractional) != 1 || !quoteShortMatches(fractional[0], "AAPL", 342.395, 3.415, 20_201_922.82733) { + t.Fatalf("quote_short_fractional_volume = %+v", fractional) } } @@ -128,7 +135,7 @@ func TestRequiredMembersAreEnforcedLikeSerde(t *testing.T) { member string }{ {"missing member", `[{"symbol":"AAPL","price":1.5,"volume":1}]`, "change"}, - {"null member", `[{"symbol":"AAPL","price":null,"change":0,"volume":1}]`, "price"}, + {"null member", `[{"symbol":null,"price":1.5,"change":0,"volume":1}]`, "symbol"}, {"empty object", `[{}]`, "symbol"}, {"null element", `[null]`, "symbol"}, } diff --git a/sdk/go/quote_test.go b/sdk/go/quote_test.go index 27dfb581..af6e16a7 100644 --- a/sdk/go/quote_test.go +++ b/sdk/go/quote_test.go @@ -66,11 +66,11 @@ func TestQuoteMethodsUseExactPathsQueriesAndHeaderAuthentication(t *testing.T) { t.Fatalf("Full = %+v, %v", full, err) } short, err := client.Quote.Short(ctx, NewQuoteShortQuery("^VIX")) - if err != nil || len(short) != 1 || short[0].Volume != 28_718_014 { + if err != nil || len(short) != 1 || short[0].Volume == nil || *short[0].Volume != 28_718_014 { t.Fatalf("Short = %+v, %v", short, err) } etfs, err := client.Quote.ETFs(ctx) - if err != nil || len(etfs) != 1 || etfs[0].Symbol != "P60.SI" || etfs[0].Volume != 1 { + if err != nil || len(etfs) != 1 || etfs[0].Symbol != "P60.SI" || etfs[0].Volume == nil || *etfs[0].Volume != 1 { t.Fatalf("ETFs = %+v, %v", etfs, err) } @@ -83,7 +83,7 @@ func TestQuoteMethodsUseExactPathsQueriesAndHeaderAuthentication(t *testing.T) { t.Fatalf("Exchange = %+v, %v", exchange, err) } trades, err := client.Quote.AftermarketTrade(ctx, NewAftermarketTradeQuery("AAPL")) - if err != nil || len(trades) != 1 || trades[0].TradeSize != 16 { + if err != nil || len(trades) != 1 || trades[0].TradeSize == nil || *trades[0].TradeSize != 16 { t.Fatalf("AftermarketTrade = %+v, %v", trades, err) } // The closed short-only universes always send short=true and decode the @@ -215,7 +215,7 @@ func TestQuoteShortDecodesTheFractionalVolumeObservedLive(t *testing.T) { client := newClient(t, server, WithAuthentication(FMPHeader("route-secret"))) short, err := client.Quote.Short(context.Background(), NewQuoteShortQuery("AAPL")) - if err != nil || len(short) != 1 || short[0].Volume != 20_201_922.82733 || short[0].Price != 342.395 { + if err != nil || len(short) != 1 || !quoteShortMatches(short[0], "AAPL", 342.395, 3.415, 20_201_922.82733) { t.Fatalf("Short = %+v, %v, want the fractional volume decoded", short, err) } }