Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions crates/fmp-py/python/fmp/_native/quote/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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: ...
Expand All @@ -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]: ...
Expand Down Expand Up @@ -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: ...
Expand Down
12 changes: 6 additions & 6 deletions crates/fmp-py/src/models/quote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,11 +311,11 @@ pub(crate) struct QuoteShort {
#[pyo3(get)]
pub symbol: String,
#[pyo3(get)]
pub price: f64,
pub price: Option<f64>,
#[pyo3(get)]
pub change: f64,
pub change: Option<f64>,
#[pyo3(get)]
pub volume: f64,
pub volume: Option<f64>,
}

#[gen_stub_pymethods]
Expand All @@ -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<f64>, change: Option<f64>, volume: Option<f64>) -> Self {
Self {
symbol,
price,
Expand Down Expand Up @@ -419,7 +419,7 @@ pub(crate) struct AftermarketTrade {
#[pyo3(get)]
pub price: f64,
#[pyo3(get)]
pub trade_size: f64,
pub trade_size: Option<f64>,
#[pyo3(get)]
pub timestamp: i64,
}
Expand All @@ -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<f64>, timestamp: i64) -> Self {
Self {
symbol,
price,
Expand Down
11 changes: 11 additions & 0 deletions crates/fmp-py/tests/test_quote.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions crates/fmp-py/tests/typecheck_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__
Expand Down Expand Up @@ -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)
Expand Down
26 changes: 19 additions & 7 deletions crates/libfmp/src/responses/quote.rs
Original file line number Diff line number Diff line change
@@ -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<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer)
}

/// A detailed real-time stock quote.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
Expand Down Expand Up @@ -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<Price>,
#[serde(deserialize_with = "required_option")]
pub change: Option<Change>,
#[serde(deserialize_with = "required_option")]
#[serde(serialize_with = "crate::codecs::integral_f64::serialize_option")]
pub volume: Option<Volume>,
}

/// One trade executed after regular US market hours.
Expand All @@ -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<Quantity>,
pub timestamp: UnixMilliseconds,
}

Expand Down
6 changes: 3 additions & 3 deletions crates/libfmp/tests/asset_catalog_quote_endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion crates/libfmp/tests/asset_catalog_quote_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ fn crypto_quotes_preserve_large_volume_market_cap_and_nullable_market_cap() {

let compact: Vec<CryptocurrencyQuoteShort> =
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]
Expand Down
6 changes: 6 additions & 0 deletions crates/libfmp/tests/fixtures/aftermarket_trade_synthetic.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,11 @@
"symbol": "000001.SZ",
"timestamp": 1700000000000,
"tradeSize": 9007199254740993
},
{
"price": 12.75,
"symbol": "000001.SZ",
"timestamp": 1700000000001,
"tradeSize": null
}
]
4 changes: 2 additions & 2 deletions crates/libfmp/tests/fixtures/quote_short_multiple.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
"volume": 4294967296
},
{
"change": 0.5,
"change": null,
"price": 18.75,
"symbol": "^VIX",
"volume": 0
"volume": null
}
]
2 changes: 1 addition & 1 deletion crates/libfmp/tests/indexes_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ fn every_documented_index_directory_and_quote_fixture_matches_the_typed_wire_con

let compact: Vec<QuoteShort> = 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<T>(fixture: &[u8])
Expand Down
4 changes: 2 additions & 2 deletions crates/libfmp/tests/integral_f64_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AftermarketTrade> = 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));
}
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions crates/libfmp/tests/quote_batch_endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
45 changes: 37 additions & 8 deletions crates/libfmp/tests/quote_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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(&quote).unwrap();
assert!(wire["price"].is_null() && wire["change"].is_null() && wire["volume"].is_null());
assert!(
serde_json::from_str::<QuoteShort>(r#"{"symbol":"VFIAX","price":1.0,"change":0.0}"#)
.is_err()
);
}

#[test]
Expand All @@ -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();
Expand Down Expand Up @@ -165,10 +186,18 @@ fn new_quote_response_arrays_preserve_empty_multiple_unknown_and_large_values()

let trades: Vec<AftermarketTrade> =
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::<AftermarketTrade>(
r#"{"symbol":"AAPL","price":1.0,"timestamp":1700000000001}"#
)
.is_err()
);

let quotes: Vec<AftermarketQuote> =
serde_json::from_str(include_str!("fixtures/aftermarket_quote_synthetic.json")).unwrap();
Expand Down
14 changes: 7 additions & 7 deletions crates/libfmp/tests/quote_short_endpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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);
}
Expand Down
Loading
Loading