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
24 changes: 12 additions & 12 deletions crates/fmp-py/python/fmp/_native/funds/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,13 @@ class EtfFundHolding:
@property
def symbol(self) -> builtins.str: ...
@property
def asset(self) -> builtins.str: ...
def asset(self) -> typing.Optional[builtins.str]: ...
@property
def name(self) -> builtins.str: ...
@property
def isin(self) -> builtins.str: ...
def isin(self) -> typing.Optional[builtins.str]: ...
@property
def security_cusip(self) -> builtins.str: ...
def security_cusip(self) -> typing.Optional[builtins.str]: ...
@property
def shares_number(self) -> builtins.float: ...
@property
Expand All @@ -122,10 +122,10 @@ class EtfFundHolding:
cls,
*,
symbol: builtins.str,
asset: builtins.str,
asset: typing.Optional[builtins.str],
name: builtins.str,
isin: builtins.str,
security_cusip: builtins.str,
isin: typing.Optional[builtins.str],
security_cusip: typing.Optional[builtins.str],
shares_number: builtins.float,
weight_percentage: builtins.float,
market_value: builtins.float,
Expand Down Expand Up @@ -326,7 +326,7 @@ class FundDisclosure:
@property
def accepted_date(self) -> datetime.datetime: ...
@property
def symbol(self) -> builtins.str: ...
def symbol(self) -> typing.Optional[builtins.str]: ...
@property
def name(self) -> builtins.str: ...
@property
Expand All @@ -336,7 +336,7 @@ class FundDisclosure:
@property
def cusip(self) -> builtins.str: ...
@property
def isin(self) -> builtins.str: ...
def isin(self) -> typing.Optional[builtins.str]: ...
@property
def balance(self) -> builtins.float: ...
@property
Expand Down Expand Up @@ -372,12 +372,12 @@ class FundDisclosure:
cik: builtins.str,
date: datetime.date,
accepted_date: datetime.datetime,
symbol: builtins.str,
symbol: typing.Optional[builtins.str],
name: builtins.str,
lei: builtins.str,
title: builtins.str,
cusip: builtins.str,
isin: builtins.str,
isin: typing.Optional[builtins.str],
balance: builtins.float,
units: builtins.str,
currency_code: builtins.str,
Expand Down Expand Up @@ -488,7 +488,7 @@ class FundDisclosureSearchResult:
@property
def reporting_file_number(self) -> builtins.str: ...
@property
def address(self) -> builtins.str: ...
def address(self) -> typing.Optional[builtins.str]: ...
@property
def city(self) -> builtins.str: ...
@property
Expand All @@ -508,7 +508,7 @@ class FundDisclosureSearchResult:
series_name: builtins.str,
class_name: builtins.str,
reporting_file_number: builtins.str,
address: builtins.str,
address: typing.Optional[builtins.str],
city: builtins.str,
zip_code: builtins.str,
state: builtins.str,
Expand Down
34 changes: 17 additions & 17 deletions crates/fmp-py/src/models/funds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ pub(crate) struct EtfFundHolding {
#[pyo3(get)]
pub symbol: String,
#[pyo3(get)]
pub asset: String,
pub asset: Option<String>,
#[pyo3(get)]
pub name: String,
#[pyo3(get)]
pub isin: String,
pub isin: Option<String>,
#[pyo3(get)]
pub security_cusip: String,
pub security_cusip: Option<String>,
#[pyo3(get)]
pub shares_number: f64,
#[pyo3(get)]
Expand All @@ -38,10 +38,10 @@ impl EtfFundHolding {
#[pyo3(signature = (*, symbol, asset, name, isin, security_cusip, shares_number, weight_percentage, market_value, updated_at))]
fn new(
symbol: String,
asset: String,
asset: Option<String>,
name: String,
isin: String,
security_cusip: String,
isin: Option<String>,
security_cusip: Option<String>,
shares_number: f64,
weight_percentage: f64,
market_value: f64,
Expand Down Expand Up @@ -190,10 +190,10 @@ impl From<libfmp::responses::funds::EtfFundHolding> for EtfFundHolding {
fn from(value: libfmp::responses::funds::EtfFundHolding) -> Self {
Self {
symbol: value.symbol.into_inner(),
asset: value.asset.into_inner(),
asset: value.asset.map(|value| value.into_inner()),
name: value.name,
isin: value.isin.into_inner(),
security_cusip: value.security_cusip.into_inner(),
isin: value.isin.map(|value| value.into_inner()),
security_cusip: value.security_cusip.map(|value| value.into_inner()),
shares_number: value.shares_number,
weight_percentage: value.weight_percentage,
market_value: value.market_value,
Expand Down Expand Up @@ -1187,7 +1187,7 @@ pub(crate) struct FundDisclosure {
#[pyo3(get)]
pub accepted_date: ::chrono::NaiveDateTime,
#[pyo3(get)]
pub symbol: String,
pub symbol: Option<String>,
#[pyo3(get)]
pub name: String,
#[pyo3(get)]
Expand All @@ -1197,7 +1197,7 @@ pub(crate) struct FundDisclosure {
#[pyo3(get)]
pub cusip: String,
#[pyo3(get)]
pub isin: String,
pub isin: Option<String>,
#[pyo3(get)]
pub balance: f64,
#[pyo3(get)]
Expand Down Expand Up @@ -1235,12 +1235,12 @@ impl FundDisclosure {
cik: String,
date: ::chrono::NaiveDate,
accepted_date: ::chrono::NaiveDateTime,
symbol: String,
symbol: Option<String>,
name: String,
lei: String,
title: String,
cusip: String,
isin: String,
isin: Option<String>,
balance: f64,
units: String,
currency_code: String,
Expand Down Expand Up @@ -1571,12 +1571,12 @@ impl From<libfmp::responses::funds::FundDisclosure> for FundDisclosure {
cik: value.cik.into_inner(),
date: value.date.into_inner(),
accepted_date: value.accepted_date.into_inner(),
symbol: value.symbol.into_inner(),
symbol: value.symbol.map(|value| value.into_inner()),
name: value.name,
lei: value.lei.into_inner(),
title: value.title,
cusip: value.cusip.into_inner(),
isin: value.isin.into_inner(),
isin: value.isin.map(|value| value.into_inner()),
balance: value.balance,
units: value.units,
currency_code: value.currency_code.into_inner(),
Expand Down Expand Up @@ -1624,7 +1624,7 @@ pub(crate) struct FundDisclosureSearchResult {
#[pyo3(get)]
pub reporting_file_number: String,
#[pyo3(get)]
pub address: String,
pub address: Option<String>,
#[pyo3(get)]
pub city: String,
#[pyo3(get)]
Expand All @@ -1650,7 +1650,7 @@ impl FundDisclosureSearchResult {
series_name: String,
class_name: String,
reporting_file_number: String,
address: String,
address: Option<String>,
city: String,
zip_code: String,
state: String,
Expand Down
15 changes: 15 additions & 0 deletions crates/fmp-py/tests/test_funds_a.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ def test_etf_holdings_decodes_the_space_timestamp(client: Any, fixture_server: F
assert row.updated_at == datetime.datetime(2026, 7, 30, 8, 7, 21)


@pytest.mark.parametrize("wire", [None, ""])
def test_etf_holdings_decodes_null_and_empty_codes_as_none(
client: Any, fixture_server: FixtureServer, wire: str | None
) -> None:
"""``etf_holdings`` surfaces a null or empty ``asset``, ``isin``, and ``securityCusip`` as ``None``."""
body = load_fixture("etf_fund_holdings.json")
body[0].update(asset=wire, isin=wire, securityCusip=wire)
fixture_server.route("/etf/holdings", body)
row = client.funds.etf_holdings("SPY")[0]

assert row.asset is None
assert row.isin is None
assert row.security_cusip is None


def test_etf_holdings_encodes_a_spaced_symbol(client: Any, fixture_server: FixtureServer) -> None:
"""``etf_holdings`` form-encodes spaces and slashes in the ticker exactly as libfmp does."""
fixture_server.route("/etf/holdings", load_fixture("etf_fund_holdings.json"))
Expand Down
16 changes: 16 additions & 0 deletions crates/fmp-py/tests/test_funds_b.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,26 @@ def test_search_fund_disclosure_holders_encodes_punctuation(client: Any, fixture
assert row.entity_org_type == "30"
assert row.class_name == "Class A Shares"
assert row.reporting_file_number == "811-03266"
assert row.address == "4000 ERICSSON DRIVE"
assert row.zip_code == "15086-7561"
assert row.state == "PA"


def test_disclosure_rows_decode_omitted_members_as_none(client: Any, fixture_server: FixtureServer) -> None:
"""A null ``symbol``, an empty ``isin``, and a null ``address`` decode as ``None``."""
disclosure = load_fixture("fund_disclosures.json")
disclosure[0].update(symbol=None, isin="")
fixture_server.route("/funds/disclosure", disclosure)
search = load_fixture("fund_disclosure_holder_search.json")
search[0]["address"] = None
fixture_server.route("/funds/disclosure-holders-search", search)

position = client.funds.disclosures("VWO", 2023, 4)[0]
assert position.symbol is None
assert position.isin is None
assert client.funds.search_disclosure_holders(FEDERATED)[0].address is None


def test_search_fund_disclosure_holders_with_a_plain_name(client: Any, fixture_server: FixtureServer) -> None:
"""``search_disclosure_holders`` sends a space-separated name as ``+`` and nothing else."""
fixture_server.route("/funds/disclosure-holders-search", load_fixture("fund_disclosure_holder_search.json"))
Expand Down
4 changes: 3 additions & 1 deletion crates/fmp-py/tests/typecheck_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,14 +211,16 @@ def check_funds_contract(client: FmpClient) -> None:
funds: FundsNamespace = client.funds
holdings: list[EtfFundHolding] = funds.etf_holdings("SPY")
updated: datetime.datetime = holdings[0].updated_at
asset: str | None = holdings[0].asset
info: list[EtfFundInfo] = client.funds.etf_info("SPY")
inception: datetime.date = info[0].inception_date
exposure: float = info[0].sectors_list[0].exposure
positions: list[FundDisclosure] = client.funds.disclosures("VWO", 2023, 4, cik="0000857489")
accepted: datetime.datetime = positions[0].accepted_date
position_symbol: str | None = positions[0].symbol
dates: list[Form13fFilingDate] = client.funds.disclosure_dates("VWO", cik="0000036405")
quarter: int = dates[0].quarter
_ = (updated, inception, exposure, accepted, quarter)
_ = (updated, asset, inception, exposure, accepted, position_symbol, quarter)


def check_indexes_contract(client: FmpClient) -> None:
Expand Down
28 changes: 21 additions & 7 deletions crates/libfmp/src/responses/funds.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Response rows returned by ETF and mutual-fund endpoints.

use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};

use crate::{
codecs::{IsoTimestamp, NumericString, PercentString, YnFlag},
Expand All @@ -10,16 +10,27 @@ use crate::{
},
};

fn required_option<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de>,
{
Option::<T>::deserialize(deserializer)
}

/// One asset held by an ETF or mutual fund.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "camelCase")]
pub struct EtfFundHolding {
pub symbol: Ticker,
pub asset: Ticker,
#[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")]
pub asset: Option<Ticker>,
pub name: String,
pub isin: Isin,
pub security_cusip: Cusip,
#[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")]
pub isin: Option<Isin>,
#[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")]
pub security_cusip: Option<Cusip>,
#[serde(serialize_with = "crate::codecs::integral_f64::serialize")]
pub shares_number: Quantity,
pub weight_percentage: Percentage,
Expand Down Expand Up @@ -123,12 +134,14 @@ pub struct FundDisclosure {
pub cik: Cik,
pub date: Date,
pub accepted_date: ApiDateTime,
pub symbol: Ticker,
#[serde(deserialize_with = "required_option")]
pub symbol: Option<Ticker>,
pub name: String,
pub lei: Lei,
pub title: String,
pub cusip: Cusip,
pub isin: Isin,
#[serde(deserialize_with = "crate::codecs::empty_or_null::deserialize")]
pub isin: Option<Isin>,
#[serde(serialize_with = "crate::codecs::integral_f64::serialize")]
pub balance: Quantity,
pub units: String,
Expand Down Expand Up @@ -162,7 +175,8 @@ pub struct FundDisclosureSearchResult {
pub series_name: String,
pub class_name: String,
pub reporting_file_number: String,
pub address: String,
#[serde(deserialize_with = "required_option")]
pub address: Option<String>,
pub city: String,
pub zip_code: String,
pub state: String,
Expand Down
5 changes: 4 additions & 1 deletion crates/libfmp/tests/fund_disclosure_endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ async fn custom_proxy_preserves_exact_query_order_encoding_headers_and_fixture_i
assert_eq!(latest[0].cik.as_str(), "0000866256");
assert_eq!(latest[0].security_cusip.as_str(), "037833100");
assert_eq!(latest[0].change, -316_881.0);
assert_eq!(disclosures[0].symbol.as_str(), "000089.SZ");
assert_eq!(
disclosures[0].symbol.as_ref().unwrap().as_str(),
"000089.SZ"
);
assert_eq!(disclosures[0].cusip.as_str(), "N/A");
assert_eq!(disclosures[0].currency_code.as_str(), "CNY");
assert_eq!(disclosures[0].fair_val_level.as_str(), "2");
Expand Down
Loading
Loading