diff --git a/crates/fmp-py-gen/src/bin/gen_go/types.rs b/crates/fmp-py-gen/src/bin/gen_go/types.rs index 6e459d1f..904ca3f9 100644 --- a/crates/fmp-py-gen/src/bin/gen_go/types.rs +++ b/crates/fmp-py-gen/src/bin/gen_go/types.rs @@ -377,6 +377,7 @@ fn scalar(ident: &str) -> Option<&'static str> { | "ChartTimeframe" | "EconomicIndicator" => "string", "NumberOrNumericString" | "PercentageValue" => "NumberOrString", "Date" => "Date", + "DateOrYear" => "DateOrYear", "UsDate" => "USDate", "ApiDateTime" => "DateTime", "UnixSeconds" => "UnixSeconds", diff --git a/crates/fmp-py-gen/src/bin/gen_models/classify.rs b/crates/fmp-py-gen/src/bin/gen_models/classify.rs index f8823063..18803496 100644 --- a/crates/fmp-py-gen/src/bin/gen_models/classify.rs +++ b/crates/fmp-py-gen/src/bin/gen_models/classify.rs @@ -41,6 +41,13 @@ pub(crate) fn classify(ident: &str, registry: &Registry, depth: usize) -> Class transform: Transform::IntoInner, }; } + if ident == "DateOrYear" { + let path = "crate::models::convert::DateOrYear".to_string(); + return Class::Scalar { + model_ty: path.clone(), + transform: Transform::Nested(path), + }; + } if ident == "ApiDateTime" { return Class::Scalar { model_ty: "::chrono::NaiveDateTime".to_string(), diff --git a/crates/fmp-py/python/fmp/_native/calendar/__init__.pyi b/crates/fmp-py/python/fmp/_native/calendar/__init__.pyi index 37301da4..6fdbb4a7 100644 --- a/crates/fmp-py/python/fmp/_native/calendar/__init__.pyi +++ b/crates/fmp-py/python/fmp/_native/calendar/__init__.pyi @@ -345,7 +345,7 @@ class IpoProspectus: @property def filing_date(self) -> datetime.date: ... @property - def ipo_date(self) -> datetime.date: ... + def ipo_date(self) -> datetime.date | builtins.int: ... @property def cik(self) -> builtins.str: ... @property @@ -373,7 +373,7 @@ class IpoProspectus: symbol: builtins.str, accepted_date: datetime.date, filing_date: datetime.date, - ipo_date: datetime.date, + ipo_date: datetime.date | builtins.int, cik: builtins.str, price_public_per_share: builtins.float, price_public_total: builtins.float, diff --git a/crates/fmp-py/src/models/calendar.rs b/crates/fmp-py/src/models/calendar.rs index 9ae1d3d3..299c3cd6 100644 --- a/crates/fmp-py/src/models/calendar.rs +++ b/crates/fmp-py/src/models/calendar.rs @@ -828,7 +828,7 @@ pub(crate) struct IpoProspectus { #[pyo3(get)] pub filing_date: ::chrono::NaiveDate, #[pyo3(get)] - pub ipo_date: ::chrono::NaiveDate, + pub ipo_date: crate::models::convert::DateOrYear, #[pyo3(get)] pub cik: String, #[pyo3(get)] @@ -860,7 +860,7 @@ impl IpoProspectus { symbol: String, accepted_date: ::chrono::NaiveDate, filing_date: ::chrono::NaiveDate, - ipo_date: ::chrono::NaiveDate, + ipo_date: crate::models::convert::DateOrYear, cik: String, price_public_per_share: f64, price_public_total: f64, @@ -1100,7 +1100,7 @@ impl From for IpoProspectus { symbol: value.symbol.into_inner(), accepted_date: value.accepted_date.into_inner(), filing_date: value.filing_date.into_inner(), - ipo_date: value.ipo_date.into_inner(), + ipo_date: crate::models::convert::DateOrYear::from(value.ipo_date), cik: value.cik.into_inner(), price_public_per_share: value.price_public_per_share, price_public_total: value.price_public_total, diff --git a/crates/fmp-py/src/models/convert.rs b/crates/fmp-py/src/models/convert.rs index 9936a9de..d02a7a0b 100644 --- a/crates/fmp-py/src/models/convert.rs +++ b/crates/fmp-py/src/models/convert.rs @@ -243,3 +243,31 @@ mod tests { }); } } + +/// A `datetime.date | int` model member: a full date stays a `datetime.date` +/// and a bare year the provider sent instead stays an `int`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoPyObject, FromPyObject)] +pub(crate) enum DateOrYear { + #[pyo3(transparent, annotation = "datetime.date")] + Date(chrono::NaiveDate), + #[pyo3(transparent, annotation = "int")] + Year(u16), +} + +pyo3_stub_gen::impl_stub_type!(DateOrYear = chrono::NaiveDate | u16); + +impl From for DateOrYear { + fn from(value: libfmp::codecs::DateOrYear) -> Self { + match (value.as_date(), value.as_year()) { + (Some(date), _) => Self::Date(date.into_inner()), + (None, Some(year)) => Self::Year(year), + (None, None) => unreachable!("libfmp DateOrYear holds a date or a year"), + } + } +} + +impl DictValue for DateOrYear { + fn dict_value<'py>(&self, py: Python<'py>) -> PyResult> { + (*self).into_bound_py_any(py) + } +} diff --git a/crates/fmp-py/tests/test_calendar_b.py b/crates/fmp-py/tests/test_calendar_b.py index 6daaccdc..81d28e9f 100644 --- a/crates/fmp-py/tests/test_calendar_b.py +++ b/crates/fmp-py/tests/test_calendar_b.py @@ -8,6 +8,7 @@ """ import datetime +import pickle from types import SimpleNamespace from typing import Any @@ -111,6 +112,26 @@ def test_ipos_null_exchange_and_discounts_are_none(client: Any, fixture_server: assert row.discounts_and_commissions_total is None +def test_ipos_prospectus_year_only_ipo_date_is_an_int( + client: Any, fixture_server: FixtureServer, errors: SimpleNamespace +) -> None: + """Issue #379: a bare ``"2020"`` ``ipoDate`` decodes as the ``int`` year, a full date stays a date.""" + prospectus = load_fixture("ipos_prospectus.json") + prospectus[0]["ipoDate"] = "2020" + fixture_server.route("/ipos-prospectus", prospectus) + row = client.calendar.ipos_prospectus()[0] + assert row.ipo_date == 2020 + assert type(row.ipo_date) is int + assert row.to_dict()["ipo_date"] == 2020 + assert pickle.loads(pickle.dumps(row)) == row + + prospectus[0]["ipoDate"] = "20x0" + fixture_server.route("/ipos-prospectus", prospectus) + with pytest.raises(errors.FmpDecodeError) as raised: + client.calendar.ipos_prospectus() + assert (raised.value.decode_path, raised.value.decode_kind) == ("[0].ipoDate", "invalid_value") + + def test_ipos_prospectus_with_both_dates(client: Any, fixture_server: FixtureServer) -> None: """``ipos_prospectus`` encodes both ISO strings in ``from``, ``to`` order.""" fixture_server.route("/ipos-prospectus", load_fixture("ipos_prospectus.json")) diff --git a/crates/fmp-py/tests/test_models.py b/crates/fmp-py/tests/test_models.py index 275bf3f6..72f77c1a 100644 --- a/crates/fmp-py/tests/test_models.py +++ b/crates/fmp-py/tests/test_models.py @@ -87,6 +87,7 @@ def _sample(annotation: str, module: str) -> Any: NUMBER: 2.5, f"{NUMBER} | None": 3, "datetime.date": datetime.date(2024, 1, 2), + "datetime.date | builtins.int": 2020, "datetime.datetime": datetime.datetime(2024, 1, 2, 3, 4, 5), } if annotation in simple: diff --git a/crates/fmp-py/tests/typecheck_contract.py b/crates/fmp-py/tests/typecheck_contract.py index 6be82201..b5ef25aa 100644 --- a/crates/fmp-py/tests/typecheck_contract.py +++ b/crates/fmp-py/tests/typecheck_contract.py @@ -140,7 +140,8 @@ def check_calendar_contract(client: FmpClient) -> None: eps_estimated: float | None = earnings[0].eps_estimated ipos: list[IpoCalendarEvent] = calendar.ipos_calendar(from_="2026-03-06", to=datetime.date(2026, 6, 6)) ipo_date: datetime.date = ipos[0].date - _ = (yield_, declared, eps_actual, eps_estimated, ipo_date) + prospectus_ipo_date: datetime.date | int = calendar.ipos_prospectus()[0].ipo_date + _ = (yield_, declared, eps_actual, eps_estimated, ipo_date, prospectus_ipo_date) def check_bulk_contract(client: FmpClient) -> None: diff --git a/crates/libfmp/src/codecs.rs b/crates/libfmp/src/codecs.rs index 4ac24f6a..a660d4d4 100644 --- a/crates/libfmp/src/codecs.rs +++ b/crates/libfmp/src/codecs.rs @@ -576,6 +576,77 @@ impl<'de> Deserialize<'de> for DateOrDateTime { } } +/// A field documented as a `YYYY-MM-DD` date that the provider sometimes sends +/// as a bare `YYYY` year. A year is kept as a year, never widened to a date. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum DateOrYear { + Date(Date), + Year(u16), +} + +impl DateOrYear { + pub const fn as_date(self) -> Option { + match self { + Self::Date(value) => Some(value), + Self::Year(_) => None, + } + } + + pub const fn as_year(self) -> Option { + match self { + Self::Date(_) => None, + Self::Year(value) => Some(value), + } + } +} + +impl fmt::Display for DateOrYear { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Date(value) => value.fmt(formatter), + Self::Year(value) => write!(formatter, "{value:04}"), + } + } +} + +impl FromStr for DateOrYear { + type Err = InvalidTemporalValue; + + fn from_str(value: &str) -> Result { + if value.len() == 10 { + Date::parse(value).map(Self::Date) + } else if value.len() == 4 && value.bytes().all(|byte| byte.is_ascii_digit()) { + value + .parse() + .map(Self::Year) + .map_err(|_| InvalidTemporalValue::new("YYYY-MM-DD date or YYYY year")) + } else { + Err(InvalidTemporalValue::new("YYYY-MM-DD date or YYYY year")) + } + } +} + +impl Serialize for DateOrYear { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for DateOrYear { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + String::deserialize(deserializer)? + .parse() + .map_err(de::Error::custom) + } +} + /// A US-formatted date represented exactly as `MM-DD-YYYY`. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct UsDate(NaiveDate); diff --git a/crates/libfmp/src/responses/calendar.rs b/crates/libfmp/src/responses/calendar.rs index 133d6048..0e9c47f8 100644 --- a/crates/libfmp/src/responses/calendar.rs +++ b/crates/libfmp/src/responses/calendar.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Deserializer, Serialize}; use crate::{ - codecs::{DynamicJson, IsoTimestamp, empty_or_null_date}, + codecs::{DateOrYear, DynamicJson, IsoTimestamp, empty_or_null_date}, types::{Cik, Date, ExchangeCode, MarketValue, Percentage, Price, SplitTerm, Ticker}, }; @@ -96,7 +96,7 @@ pub struct IpoProspectus { pub symbol: Ticker, pub accepted_date: Date, pub filing_date: Date, - pub ipo_date: Date, + pub ipo_date: DateOrYear, pub cik: Cik, pub price_public_per_share: Price, #[serde(serialize_with = "crate::codecs::integral_f64::serialize")] diff --git a/crates/libfmp/tests/calendar_responses.rs b/crates/libfmp/tests/calendar_responses.rs index 3b0553b9..9c79f591 100644 --- a/crates/libfmp/tests/calendar_responses.rs +++ b/crates/libfmp/tests/calendar_responses.rs @@ -5,7 +5,7 @@ mod assert_row; use std::str::FromStr; use libfmp::{ - codecs::IsoTimestamp, + codecs::{DateOrYear, IsoTimestamp}, responses::calendar::{ DividendEvent, EarningsEvent, IpoCalendarEvent, IpoDisclosure, IpoProspectus, StockSplitEvent, @@ -164,7 +164,7 @@ fn exact_ipo_prospectus_fixture_decodes_all_thirteen_fields() { symbol: Ticker::new("FTW-WT").unwrap(), accepted_date: Date::from_str("2026-07-29").unwrap(), filing_date: Date::from_str("2026-07-30").unwrap(), - ipo_date: Date::from_str("2026-07-28").unwrap(), + ipo_date: DateOrYear::Date(Date::from_str("2026-07-28").unwrap()), cik: Cik::new("0002083125").unwrap(), price_public_per_share: 1.0, price_public_total: 434.0, @@ -178,6 +178,25 @@ fn exact_ipo_prospectus_fixture_decodes_all_thirteen_fields() { ); } +#[test] +fn ipo_prospectus_year_only_ipo_date_decodes_as_a_year() { + let mut rows: serde_json::Value = serde_json::from_slice(IPOS_PROSPECTUS).unwrap(); + rows[0]["ipoDate"] = "2020".into(); + let decoded: Vec = serde_json::from_value(rows.clone()).unwrap(); + assert_eq!(decoded[0].ipo_date, DateOrYear::Year(2020)); + assert_eq!(decoded[0].ipo_date.as_date(), None); + assert_eq!( + serde_json::to_value(&decoded).unwrap()[0]["ipoDate"], + "2020" + ); + + rows[0]["ipoDate"] = "20x0".into(); + let message = serde_json::from_value::>(rows) + .unwrap_err() + .to_string(); + assert!(!message.contains("20x0"), "error echoes the value"); +} + #[test] fn both_exact_stock_split_fixtures_share_the_five_field_integer_row() { assert_field_count(STOCK_SPLITS, 5); diff --git a/crates/libfmp/tests/shared_wire_codecs.rs b/crates/libfmp/tests/shared_wire_codecs.rs index 78fd02c9..90d49735 100644 --- a/crates/libfmp/tests/shared_wire_codecs.rs +++ b/crates/libfmp/tests/shared_wire_codecs.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use libfmp::{ codecs::{ - DateOrDateTime, DynamicJson, DynamicObject, FiscalYear, IsoTimestamp, + DateOrDateTime, DateOrYear, DynamicJson, DynamicObject, FiscalYear, IsoTimestamp, NumberOrNumericString, OpaqueDateText, PercentageValue, TitleCaseBoolFlag, TrueFalseFlag, UsDate, WireBool, YesNoFlag, YnFlag, empty_date, empty_or_null_date, empty_or_null_date_or_datetime, @@ -116,6 +116,35 @@ fn temporal_contracts_are_strict_and_empty_or_null_aware() { )); assert!(DateOrDateTime::from_str("2026-07-30T00:00:00Z").is_err()); + let year: DateOrYear = serde_json::from_str(r#""2020""#).unwrap(); + assert_eq!(year, DateOrYear::Year(2020)); + assert_eq!((year.as_year(), year.as_date()), (Some(2020), None)); + assert_eq!(serde_json::to_string(&year).unwrap(), r#""2020""#); + let date: DateOrYear = serde_json::from_str(r#""2026-07-28""#).unwrap(); + assert_eq!(date, DateOrYear::Date(Date::parse("2026-07-28").unwrap())); + assert_eq!(date.as_year(), None); + assert_eq!(serde_json::to_string(&date).unwrap(), r#""2026-07-28""#); + for garbage in [ + r#""20x0""#, + r#""20201""#, + r#""202""#, + r#""2026-13-01""#, + r#""2026-07-28 00:00:00""#, + r#"" 2020""#, + r#""""#, + ] { + let message = serde_json::from_str::(garbage) + .unwrap_err() + .to_string(); + let inner = garbage.trim_matches('"'); + assert!( + inner.is_empty() || !message.contains(inner), + "{garbage}: error echoes the value" + ); + } + assert!(serde_json::from_str::("2020").is_err()); + assert!(serde_json::from_str::("null").is_err()); + for wire in [ r#"{"declaration_date":"","filing_date":null}"#, r#"{"declaration_date":null,"filing_date":""}"#, diff --git a/docs/adr/0002-shared-wire-types-and-python-conversions.md b/docs/adr/0002-shared-wire-types-and-python-conversions.md index 4b3020cb..b8799c8d 100644 --- a/docs/adr/0002-shared-wire-types-and-python-conversions.md +++ b/docs/adr/0002-shared-wire-types-and-python-conversions.md @@ -48,7 +48,8 @@ The Python binding applies this conversion policy to public endpoint models offset spelling and millisecond precision are preserved rather than normalised into a timezone-aware `datetime.datetime`. Date-or-datetime fields remain `str`, preserving which of the two documented forms was - received. Empty and null optional dates become `None`. Opaque human or + received. Date-or-year fields (ADR 0034) become `datetime.date | int`. + Empty and null optional dates become `None`. Opaque human or partial date text remains `str`. - Dynamic JSON recursively becomes native Python dictionaries, lists, strings, integers, floats, booleans, and `None`. diff --git a/docs/adr/0030-go-sdk-architecture.md b/docs/adr/0030-go-sdk-architecture.md index daa87c70..fcb65715 100644 --- a/docs/adr/0030-go-sdk-architecture.md +++ b/docs/adr/0030-go-sdk-architecture.md @@ -133,6 +133,7 @@ that no wire information is invented or lost: | `Date` (`YYYY-MM-DD`) | `fmp.Date` | civil date, no zone, own type | | `UsDate` (`MM-DD-YYYY`) | `fmp.UsDate` | same civil date, US wire text; `.Date()` converts | | `ApiDateTime` (naive) | `fmp.DateTime` | naive, own type; not `time.Time` | +| `DateOrYear` (`YYYY-MM-DD` or `YYYY`) | `fmp.DateOrYear` | own type; `.Date()` / `.Year()` report which form arrived (ADR 0034) | | `IsoTimestamp` | `string` | exact RFC 3339 text, offset and precision preserved | | `DateOrDateTime`, `OpaqueDateText` | `string` | which form arrived is preserved | | `UnixSeconds`, `UnixMilliseconds` | `fmp.UnixSeconds`, `fmp.UnixMilliseconds` (`int64`) | `.Time()` helpers | diff --git a/docs/adr/0034-date-or-year-members.md b/docs/adr/0034-date-or-year-members.md new file mode 100644 index 00000000..a18c465c --- /dev/null +++ b/docs/adr/0034-date-or-year-members.md @@ -0,0 +1,50 @@ +# ADR 0034: Date members that can arrive as a bare year + +- Status: Accepted +- Date: 2026-09-30 +- Decision owners: libfmp maintainers +- Scope: `IpoProspectus.ipo_date` in Rust, Python, and Go; implemented by + [#379](https://github.com/bitbrew-dev/libfmp/issues/379) (1.2.0) + +## Context + +`IpoProspectus.ipoDate` is documented as a `YYYY-MM-DD` date. The #368 live +audit decoded 239 `ipos-prospectus` rows and found 7 that carry a bare year +(`"2020"`, `"2008"`, seen over `from=2020-03-01&to=2020-03-31`). The member was +never `null`. ADR 0033 leaves "a year where a date is documented" to a +separate type fix, and one such row fails the whole response today. + +## Decision + +A member seen as either a full date or a bare year uses `codecs::DateOrYear`, +built like `DateOrDateTime`: the exact wire length picks the form, and each +form is validated strictly. + +| Wire value | Rust | Python | Go | +| --- | --- | --- | --- | +| `"2026-07-28"` | `DateOrYear::Date(Date)` | `datetime.date` | `fmp.DateOrYear`, `.Date()` reports it | +| `"2020"` | `DateOrYear::Year(2020)` (`u16`) | `int` | `fmp.DateOrYear`, `.Year()` reports it | +| anything else | decode error | `FmpDecodeError` | `*InvalidTemporalValueError` | + +- A year stays a year. `"2020"` is never widened to `2020-01-01`, which would + invent a month and a day. +- Only exactly four ASCII digits are a year; a 10-byte value must be a real + `YYYY-MM-DD` date. Errors name the expected shapes and never include the + value. Both forms re-encode as the exact wire string. +- The Python member is `datetime.date | int`, so full dates keep the + `datetime.date` type they had; the hand-written `models::convert::DateOrYear` + converts both ways. Go gets its own validating type instead of the + `string` used for `DateOrDateTime`, so a malformed value still fails. +- Evidence decides, as in ADR 0033: only `IpoProspectus.ipo_date` changes. + The other `ipoDate` members (company profiles, search, SEC filings) were + never seen as a bare year and stay `Date`. +- The change is breaking and ships in a minor release with a + `BREAKING CHANGE:` footer. + +## Alternatives considered + +- **Plain `String`.** Rejected: full dates would lose their type in every + language, and garbage would decode silently in Go. +- **Coerce the year to January 1.** Rejected: it invents information. +- **`Option` with the year dropped to `None`.** Rejected: it discards + the year the provider did send. diff --git a/sdk/go/calendar_models.go b/sdk/go/calendar_models.go index 8e300b47..8c9aa27c 100644 --- a/sdk/go/calendar_models.go +++ b/sdk/go/calendar_models.go @@ -349,19 +349,19 @@ func (m *IPODisclosure) UnmarshalJSONFrom(dec *jsontext.Decoder) error { // IPOProspectus is one US IPO prospectus filing and its documented offering // values. type IPOProspectus struct { - Symbol string `json:"symbol"` - AcceptedDate Date `json:"acceptedDate"` - FilingDate Date `json:"filingDate"` - IPODate Date `json:"ipoDate"` - CIK string `json:"cik"` - PricePublicPerShare float64 `json:"pricePublicPerShare"` - PricePublicTotal float64 `json:"pricePublicTotal"` - DiscountsAndCommissionsPerShare *float64 `json:"discountsAndCommissionsPerShare"` - DiscountsAndCommissionsTotal *float64 `json:"discountsAndCommissionsTotal"` - ProceedsBeforeExpensesPerShare float64 `json:"proceedsBeforeExpensesPerShare"` - ProceedsBeforeExpensesTotal float64 `json:"proceedsBeforeExpensesTotal"` - Form string `json:"form"` - URL string `json:"url"` + Symbol string `json:"symbol"` + AcceptedDate Date `json:"acceptedDate"` + FilingDate Date `json:"filingDate"` + IPODate DateOrYear `json:"ipoDate"` + CIK string `json:"cik"` + PricePublicPerShare float64 `json:"pricePublicPerShare"` + PricePublicTotal float64 `json:"pricePublicTotal"` + DiscountsAndCommissionsPerShare *float64 `json:"discountsAndCommissionsPerShare"` + DiscountsAndCommissionsTotal *float64 `json:"discountsAndCommissionsTotal"` + ProceedsBeforeExpensesPerShare float64 `json:"proceedsBeforeExpensesPerShare"` + ProceedsBeforeExpensesTotal float64 `json:"proceedsBeforeExpensesTotal"` + Form string `json:"form"` + URL string `json:"url"` } // ipoProspectusShadow mirrors IPOProspectus with a pointer or raw value for @@ -371,7 +371,7 @@ type ipoProspectusShadow struct { Symbol *string `json:"symbol"` AcceptedDate *Date `json:"acceptedDate"` FilingDate *Date `json:"filingDate"` - IPODate *Date `json:"ipoDate"` + IPODate *DateOrYear `json:"ipoDate"` CIK *string `json:"cik"` PricePublicPerShare *float64 `json:"pricePublicPerShare"` PricePublicTotal *float64 `json:"pricePublicTotal"` diff --git a/sdk/go/calendar_parity_test.go b/sdk/go/calendar_parity_test.go index de51e85a..21973804 100644 --- a/sdk/go/calendar_parity_test.go +++ b/sdk/go/calendar_parity_test.go @@ -174,7 +174,7 @@ func TestDocumentedIpoFilingAndStockSplitFixturesDecodeExactValues(t *testing.T) prospectus := assertFixtureParity[IPOProspectus](t, "ipos_prospectus.json") wantProspectus := IPOProspectus{ Symbol: "FTW-WT", AcceptedDate: mustParseDate(t, "2026-07-29"), FilingDate: mustParseDate(t, "2026-07-30"), - IPODate: mustParseDate(t, "2026-07-28"), CIK: "0002083125", PricePublicPerShare: 1, PricePublicTotal: 434, + IPODate: mustParseDateOrYear(t, "2026-07-28"), CIK: "0002083125", PricePublicPerShare: 1, PricePublicTotal: 434, DiscountsAndCommissionsPerShare: new(0.0), DiscountsAndCommissionsTotal: new(82_251.0), ProceedsBeforeExpensesPerShare: 1, ProceedsBeforeExpensesTotal: 82_251, Form: "S-1", URL: "https://www.sec.gov/Archives/edgar/data/2083125/000121390026082963/ea0298363-s1_presidio.htm", @@ -224,6 +224,33 @@ func assertMemberContract[T any](t *testing.T, name string, nullable ...string) } } +func TestIpoProspectusYearOnlyIpoDateDecodesAsAYear(t *testing.T) { + t.Parallel() + raw := string(readFixture(t, "ipos_prospectus.json")) + yearOnly := strings.Replace(raw, `"ipoDate": "2026-07-28"`, `"ipoDate": "2020"`, 1) + var rows []IPOProspectus + if err := json.Unmarshal([]byte(yearOnly), &rows); err != nil { + t.Fatalf("decode year-only row: %v", err) + } + if year, ok := rows[0].IPODate.Year(); !ok || year != 2020 { + t.Fatalf("IPODate.Year() = %d, %v", year, ok) + } + if _, ok := rows[0].IPODate.Date(); ok { + t.Fatal("a year-only ipoDate reported a full date") + } + encoded, err := json.Marshal(rows[0]) + if err != nil || !strings.Contains(string(encoded), `"ipoDate":"2020"`) { + t.Fatalf("re-encode = %s, %v", encoded, err) + } + + garbage := strings.Replace(raw, `"ipoDate": "2026-07-28"`, `"ipoDate": "20x0"`, 1) + err = json.Unmarshal([]byte(garbage), &rows) + var invalid *InvalidTemporalValueError + if !errors.As(err, &invalid) || strings.Contains(err.Error(), "20x0") { + t.Fatalf("garbage ipoDate error = %v", err) + } +} + func TestCalendarRequiredMembersAreEnforcedLikeSerde(t *testing.T) { t.Parallel() dividend := []string{"recordDate", "paymentDate", "declarationDate"} diff --git a/sdk/go/temporal.go b/sdk/go/temporal.go index 5a8070ac..aea58aad 100644 --- a/sdk/go/temporal.go +++ b/sdk/go/temporal.go @@ -460,3 +460,87 @@ func (d *USDate) UnmarshalJSONFrom(dec *jsontext.Decoder) error { } return d.UnmarshalText([]byte(value)) } + +const dateOrYearExpected = "YYYY-MM-DD date or YYYY year" + +// DateOrYear is a member documented as a "YYYY-MM-DD" date that the provider +// sometimes sends as a bare "YYYY" year. It mirrors DateOrYear in the Rust +// crate: a year stays a year and is never widened to a date. The zero value +// is neither and cannot be encoded; IsZero reports it. +type DateOrYear struct { + date Date + year int + yearOnly bool +} + +// ParseDateOrYear accepts exactly a "YYYY-MM-DD" date or four ASCII digits, +// the same two shapes the Rust crate accepts. +func ParseDateOrYear(value string) (DateOrYear, error) { + switch len(value) { + case 10: + date, err := ParseDate(value) + if err != nil { + return DateOrYear{}, err + } + return DateOrYear{date: date}, nil + case 4: + if hasExactASCIIShape(value, 4, nil) { + return DateOrYear{year: asciiDigits(value), yearOnly: true}, nil + } + } + return DateOrYear{}, &InvalidTemporalValueError{Expected: dateOrYearExpected} +} + +// Date returns the full date and true, or false when only a year arrived. +func (v DateOrYear) Date() (Date, bool) { + return v.date, !v.yearOnly && !v.date.IsZero() +} + +// Year returns the bare year and true, or false when a full date arrived. +func (v DateOrYear) Year() (int, bool) { + return v.year, v.yearOnly +} + +// IsZero reports whether v is the zero value, which holds neither form. +func (v DateOrYear) IsZero() bool { return v == DateOrYear{} } + +// String returns the exact wire text, "YYYY-MM-DD" or "YYYY". +func (v DateOrYear) String() string { + if v.yearOnly { + return fmt.Sprintf("%04d", v.year) + } + return v.date.String() +} + +// MarshalText encodes the wire text; the zero value is rejected. +func (v DateOrYear) MarshalText() ([]byte, error) { + if v.IsZero() { + return nil, ErrZeroTemporalValue + } + return []byte(v.String()), nil +} + +// UnmarshalText parses the exact wire text. +func (v *DateOrYear) UnmarshalText(text []byte) error { + parsed, err := ParseDateOrYear(string(text)) + if err != nil { + return err + } + *v = parsed + return nil +} + +// MarshalJSONTo writes the value as a JSON string; the zero value is rejected. +func (v DateOrYear) MarshalJSONTo(enc *jsontext.Encoder) error { + return encodeTemporalString(enc, v.String(), v.IsZero()) +} + +// UnmarshalJSONFrom reads one JSON string. Any other JSON kind, including +// null, is rejected, matching the Rust decoder for a required field. +func (v *DateOrYear) UnmarshalJSONFrom(dec *jsontext.Decoder) error { + value, err := decodeTemporalString(dec, dateOrYearExpected) + if err != nil { + return err + } + return v.UnmarshalText([]byte(value)) +} diff --git a/sdk/go/temporal_test.go b/sdk/go/temporal_test.go index 1d6449fe..2be72f49 100644 --- a/sdk/go/temporal_test.go +++ b/sdk/go/temporal_test.go @@ -3,6 +3,7 @@ package fmp import ( "encoding/json/v2" "errors" + "strings" "testing" "time" ) @@ -16,6 +17,15 @@ func mustParseDate(t *testing.T, value string) Date { return date } +func mustParseDateOrYear(t *testing.T, value string) DateOrYear { + t.Helper() + parsed, err := ParseDateOrYear(value) + if err != nil { + t.Fatalf("ParseDateOrYear(%q) error = %v", value, err) + } + return parsed +} + func mustParseDateTime(t *testing.T, value string) DateTime { t.Helper() datetime, err := ParseDateTime(value) @@ -298,3 +308,54 @@ func TestUnixTimestampsAcceptEveryInt64AndRejectOtherTokens(t *testing.T) { assertTemporalError(t, err, "integer Unix timestamp in milliseconds") } } + +func TestParseDateOrYearKeepsTheFormThatArrived(t *testing.T) { + t.Parallel() + year := mustParseDateOrYear(t, "2020") + if got, ok := year.Year(); !ok || got != 2020 { + t.Fatalf("Year() = %d, %v", got, ok) + } + if _, ok := year.Date(); ok { + t.Fatal("a bare year reported a date") + } + date := mustParseDateOrYear(t, "2026-07-28") + if got, ok := date.Date(); !ok || got != mustParseDate(t, "2026-07-28") { + t.Fatalf("Date() = %v, %v", got, ok) + } + if _, ok := date.Year(); ok { + t.Fatal("a full date reported a bare year") + } + for _, value := range []string{"20x0", "20201", "202", " 2020", "", "2026-07-28 00:00:00", "2020"} { + _, err := ParseDateOrYear(value) + assertTemporalError(t, err, dateOrYearExpected) + } + _, err := ParseDateOrYear("2026-13-01") + assertTemporalError(t, err, dateExpected) +} + +func TestDateOrYearJSONRoundTripByteIdentical(t *testing.T) { + t.Parallel() + for _, wire := range []string{`"2020"`, `"0999"`, `"2026-07-28"`} { + var value DateOrYear + if err := json.Unmarshal([]byte(wire), &value); err != nil { + t.Fatalf("Unmarshal(%s) error = %v", wire, err) + } + encoded, err := json.Marshal(value) + if err != nil || string(encoded) != wire { + t.Fatalf("Marshal = %s, %v, want %s", encoded, err, wire) + } + } + for _, wire := range []string{`2020`, `null`, `"20x0"`, `true`, `["2020"]`} { + var value DateOrYear + err := json.Unmarshal([]byte(wire), &value) + if err == nil { + t.Fatalf("Unmarshal(%s) accepted", wire) + } + if wire == `"20x0"` && strings.Contains(err.Error(), "20x0") { + t.Fatalf("error echoes the value: %v", err) + } + } + if _, err := json.Marshal(DateOrYear{}); !errors.Is(err, ErrZeroTemporalValue) { + t.Fatalf("Marshal(zero) error = %v, want ErrZeroTemporalValue", err) + } +} diff --git a/wiki/design.md b/wiki/design.md index fb184853..9d76af25 100644 --- a/wiki/design.md +++ b/wiki/design.md @@ -45,4 +45,5 @@ hold in every language: - [ADR 0031](../docs/adr/0031-pre-1.0-contract-decisions.md): pre-1.0 contract decisions (error equality without a source chain, `#[non_exhaustive]` responses, `f64` quantities, exact numeric strings, Python model equality, the `fmp` import name). - [ADR 0032](../docs/adr/0032-naming-policy.md): the naming policy for methods, row types, and Go initialisms across all three SDKs. - [ADR 0033](../docs/adr/0033-nullable-response-members.md): nullable response members, and `""` or null on typed codes and dates decoding to `None` / `nil`, from observed wire evidence. +- [ADR 0034](../docs/adr/0034-date-or-year-members.md): a date member that can arrive as a bare `YYYY` year keeps the year as a year (`DateOrYear`, Python `datetime.date | int`, Go `fmp.DateOrYear`). - [Contract ambiguities register](../docs/contract-ambiguities.md): where the captured documentation is silent or contradictory, the SDK keeps an explicit raw type, a closed query, or a strict decode rather than a guessed schema. The register lists those deferred decisions.