From 6d7b5ff688078e2d4f3f9089d395d3d78091992d Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 22:19:13 +0800 Subject: [PATCH 1/3] feat(codecs): add DateOrYear for date-or-year wire fields Add a `DateOrYear` codec that keeps a bare `YYYY` year as a year and never widens it to a date. It parses, serialises, and deserialises the two documented forms, exposes `as_date` / `as_year` to report which arrived, and rejects malformed input without echoing the offending value. Record the decision in ADR 0034 and reference it from the shared wire type, Go SDK, and design docs so Python (`datetime.date | int`) and Go (`fmp.DateOrYear`) mappings stay aligned. --- crates/libfmp/src/codecs.rs | 71 +++++++++++++++++++ crates/libfmp/tests/shared_wire_codecs.rs | 31 +++++++- ...hared-wire-types-and-python-conversions.md | 3 +- docs/adr/0030-go-sdk-architecture.md | 1 + docs/adr/0034-date-or-year-members.md | 50 +++++++++++++ wiki/design.md | 1 + 6 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0034-date-or-year-members.md 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/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/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. From a3f12be59147e2e9fd9a315b22d1e3703f41f3f8 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 22:25:49 +0800 Subject: [PATCH 2/3] feat(sdk/go): add DateOrYear temporal type Introduce a DateOrYear type that represents a member documented as a "YYYY-MM-DD" date but sometimes sent by the provider as a bare "YYYY" year. It mirrors the DateOrYear type in the Rust crate: a year stays a year and is never widened to a date, and the zero value holds neither form and cannot be encoded. **New capability:** * Added `ParseDateOrYear` accepting exactly a "YYYY-MM-DD" date or four ASCII digits, the same two shapes the Rust crate accepts. * Added `Date`, `Year`, `IsZero`, and `String` accessors that report the form that arrived and return the exact wire text. * Added text and JSON encoding via `MarshalText`, `UnmarshalText`, `MarshalJSONTo`, and `UnmarshalJSONFrom`, rejecting the zero value and any non-string JSON kind including null. **Testing:** * Added tests verifying the parsed form is preserved, invalid shapes are rejected, JSON round-trips are byte-identical, and errors do not echo the offending value. --- sdk/go/temporal.go | 84 +++++++++++++++++++++++++++++++++++++++++ sdk/go/temporal_test.go | 61 ++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) 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) + } +} From a986bf6fabe8df4dbdb2150bc789e271d8868fb1 Mon Sep 17 00:00:00 2001 From: benbenbang Date: Wed, 30 Sep 2026 22:26:35 +0800 Subject: [PATCH 3/3] fix(calendar): decode a bare year ipoDate as a year, not a date The provider sometimes sends `ipoDate` as a bare year (e.g. `"2020"`) instead of a full date on IPO prospectus rows, which previously failed to decode. Introduce a `DateOrYear` type across all three SDKs so a full date stays a date and a bare year is preserved as a year. **Response decoding:** * Change `IpoProspectus.ipo_date` in `libfmp` from `Date` to the new `DateOrYear` codec, which decodes either a full date or a bare year and re-encodes it in the shape it was received. **Python bindings:** * Add a `DateOrYear` PyO3 enum exposing the member as `datetime.date | int`, wire it into the `IpoProspectus` model and stub, and support it in `to_dict` and pickling. **Go SDK:** * Change `IPOProspectus.IPODate` to `DateOrYear`, exposing `.Date()` and `.Year()` accessors. **Tests:** * Add Rust, Python, and Go tests covering a year-only `ipoDate`, a full date, and an invalid value that must not echo the raw input in its error. Closes #379 BREAKING CHANGE: IpoProspectus.ipo_date is now DateOrYear in Rust (match Date or Year, or use as_date/as_year), datetime.date | int in Python (a bare year is an int), and fmp.DateOrYear in Go (use .Date() / .Year()). close #379 --- crates/fmp-py-gen/src/bin/gen_go/types.rs | 1 + .../fmp-py-gen/src/bin/gen_models/classify.rs | 7 +++++ .../python/fmp/_native/calendar/__init__.pyi | 4 +-- crates/fmp-py/src/models/calendar.rs | 6 ++-- crates/fmp-py/src/models/convert.rs | 28 ++++++++++++++++++ crates/fmp-py/tests/test_calendar_b.py | 21 ++++++++++++++ crates/fmp-py/tests/test_models.py | 1 + crates/fmp-py/tests/typecheck_contract.py | 3 +- crates/libfmp/src/responses/calendar.rs | 4 +-- crates/libfmp/tests/calendar_responses.rs | 23 +++++++++++++-- sdk/go/calendar_models.go | 28 +++++++++--------- sdk/go/calendar_parity_test.go | 29 ++++++++++++++++++- 12 files changed, 130 insertions(+), 25 deletions(-) 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/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/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"}