Skip to content

Commit 22149a0

Browse files
fix: coerce earnings_calendar EPS/revenue to FLOAT64 (#140) (#147)
* fix: coerce earnings_calendar EPS/revenue to FLOAT64 (#140) earnings_calendar_job failed on every run with "Value of type STRING cannot be assigned to T.eps_actual, which has type FLOAT64" because Yahoo's feed returns EPS fields as strings/None. Polars inferred a String column, the staging load typed it STRING, and the MERGE into the FLOAT64 target was rejected. Coerce eps_estimated/eps_actual via a _to_float helper (str/float/None -> float|None), and pin all four numeric columns to Float64 on the frame so an all-None batch can't reintroduce the mismatch through a Null/String staging column. Also closes the same latent hazard on the always-None revenue_estimated/revenue_actual columns. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: narrow _to_float param type to satisfy ty typecheck ty rejected float(value) when value was typed `object`. Type the parameter as the values Yahoo actually returns (str | float | int | None), which is convertible to float after the None/empty guard. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: normalize existing earnings_calendar numeric columns before MERGE (#140) Address Codex P1: a target table first created by pre-fix code can still hold eps_*/revenue_* as STRING. The FLOAT64 staging casts would then fail the MERGE assignment into a STRING target for existing deployments. Call bq.normalize_column_types("earnings_calendar", FLOAT64 types) before upsert_data — SAFE_CASTing any drifted target columns (no-op when already FLOAT64 or the table doesn't yet exist), mirroring the treasury-yields path. Hoist the numeric column list/types to module constants and add a test asserting normalize runs before upsert with FLOAT64 types. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style: apply ruff format to calendars.py Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 57b6fc2 commit 22149a0

2 files changed

Lines changed: 172 additions & 2 deletions

File tree

macro_agents/src/macro_agents/defs/domains/calendars.py

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,17 @@
6060

6161
WEEKDAY_ABBRS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
6262

63+
# earnings_calendar numeric columns typed as FLOAT64 in BigQuery. Used both to
64+
# pin the staging frame's dtypes and to normalize any drifted target columns
65+
# (older tables created by pre-fix code can hold these as STRING).
66+
EARNINGS_NUMERIC_COLUMNS = [
67+
"eps_estimated",
68+
"eps_actual",
69+
"revenue_estimated",
70+
"revenue_actual",
71+
]
72+
EARNINGS_NUMERIC_COLUMN_TYPES = {col: "FLOAT64" for col in EARNINGS_NUMERIC_COLUMNS}
73+
6374

6475
def _nth_weekday_of_month(year: int, month: int, weekday: int, n: int) -> date:
6576
"""Return the date for the nth weekday of a month (weekday: 0=Mon)."""
@@ -337,6 +348,23 @@ def parse_numeric_value(value: str | None) -> float | None:
337348
return None
338349

339350

351+
def _to_float(value: str | float | int | None) -> float | None:
352+
"""Coerce a source EPS value (str, float, or None) to float, or None.
353+
354+
Yahoo's earnings feed returns EPS fields as strings, floats, or None
355+
depending on the row. The BigQuery ``earnings_calendar`` table types these
356+
columns as FLOAT64, so blank/None values must map to NULL and everything
357+
else must be a real float before the frame is built (otherwise Polars
358+
infers a String column and the MERGE is rejected).
359+
"""
360+
if value is None or value == "":
361+
return None
362+
try:
363+
return float(value)
364+
except (TypeError, ValueError):
365+
return None
366+
367+
340368
@dg.asset(
341369
group_name=CALENDAR_GROUP,
342370
kinds={"api", "economic_calendar"},
@@ -475,8 +503,8 @@ def earnings_calendar(
475503
"company_name": row.get("company_name", ""),
476504
"report_date": report_date,
477505
"fiscal_date_ending": "",
478-
"eps_estimated": row.get("eps_estimated"),
479-
"eps_actual": row.get("eps_actual"),
506+
"eps_estimated": _to_float(row.get("eps_estimated")),
507+
"eps_actual": _to_float(row.get("eps_actual")),
480508
"revenue_estimated": None,
481509
"revenue_actual": None,
482510
"report_time": row.get("report_time", ""),
@@ -489,8 +517,22 @@ def earnings_calendar(
489517
)
490518

491519
processed_df = pl.DataFrame(processed_records)
520+
# Pin numeric columns to Float64 so the staging table always matches the
521+
# FLOAT64 raw-table schema — even for a batch where every value is None,
522+
# which would otherwise infer a Null/String column and break the MERGE.
523+
processed_df = processed_df.with_columns(
524+
pl.col(col).cast(pl.Float64, strict=False) for col in EARNINGS_NUMERIC_COLUMNS
525+
)
492526
context.log.info(f"Fetched {len(processed_df)} earnings announcements")
493527

528+
# Repair existing deployments: a target table first created by pre-fix code
529+
# can still hold these columns as STRING. The FLOAT64 staging columns above
530+
# would then fail the MERGE assignment into a STRING target, so normalize
531+
# the target's numeric columns first (SAFE_CAST; no-op if already FLOAT64
532+
# or the table doesn't yet exist).
533+
bq.normalize_column_types(
534+
"earnings_calendar", EARNINGS_NUMERIC_COLUMN_TYPES, context=context
535+
)
494536
bq.upsert_data("earnings_calendar", processed_df, ["event_id"], context=context)
495537

496538
return dg.MaterializeResult(
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""Regression tests for earnings_calendar EPS/revenue type coercion (issue #140).
2+
3+
The BigQuery ``earnings_calendar`` table types EPS and revenue columns as
4+
FLOAT64. Yahoo's source feed returns EPS fields as strings, floats, or None, so
5+
the values must be coerced to float (and the columns pinned to Float64) before
6+
the frame is loaded into a staging table and MERGEd — otherwise BigQuery rejects
7+
the MERGE with ``Value of type STRING cannot be assigned to ... FLOAT64``.
8+
"""
9+
10+
from unittest.mock import MagicMock
11+
12+
import dagster as dg
13+
import polars as pl
14+
15+
from macro_agents.defs.domains.calendars import (
16+
EARNINGS_NUMERIC_COLUMN_TYPES,
17+
_to_float,
18+
earnings_calendar,
19+
)
20+
21+
22+
class TestToFloat:
23+
def test_string_number_is_parsed(self):
24+
assert _to_float("1.23") == 1.23
25+
26+
def test_float_passthrough(self):
27+
assert _to_float(2.5) == 2.5
28+
29+
def test_integer_is_coerced(self):
30+
assert _to_float(3) == 3.0
31+
32+
def test_none_maps_to_none(self):
33+
assert _to_float(None) is None
34+
35+
def test_empty_string_maps_to_none(self):
36+
assert _to_float("") is None
37+
38+
def test_non_numeric_string_maps_to_none(self):
39+
assert _to_float("N/A") is None
40+
41+
42+
class TestNumericColumnDtypes:
43+
"""The staging frame must expose FLOAT64 columns even for all-None batches."""
44+
45+
NUMERIC_COLUMNS = [
46+
"eps_estimated",
47+
"eps_actual",
48+
"revenue_estimated",
49+
"revenue_actual",
50+
]
51+
52+
def _pin(self, records: list[dict]) -> pl.DataFrame:
53+
# Mirror the cast applied in earnings_calendar before upsert_data.
54+
df = pl.DataFrame(records)
55+
return df.with_columns(
56+
pl.col(col).cast(pl.Float64, strict=False) for col in self.NUMERIC_COLUMNS
57+
)
58+
59+
def test_all_none_batch_yields_float64(self):
60+
records = [
61+
{c: None for c in self.NUMERIC_COLUMNS},
62+
{c: None for c in self.NUMERIC_COLUMNS},
63+
]
64+
df = self._pin(records)
65+
for col in self.NUMERIC_COLUMNS:
66+
assert df.schema[col] == pl.Float64
67+
68+
def test_coerced_values_yield_float64(self):
69+
records = [
70+
{
71+
"eps_estimated": _to_float("1.1"),
72+
"eps_actual": _to_float("1.3"),
73+
"revenue_estimated": None,
74+
"revenue_actual": None,
75+
}
76+
]
77+
df = self._pin(records)
78+
for col in self.NUMERIC_COLUMNS:
79+
assert df.schema[col] == pl.Float64
80+
assert df["eps_actual"][0] == 1.3
81+
82+
83+
class TestTargetNormalization:
84+
"""The asset must normalize drifted STRING target columns before the MERGE.
85+
86+
Existing deployments can hold eps_*/revenue_* as STRING (inferred by the
87+
old code's first load). The FLOAT64 staging columns would then fail the
88+
MERGE into a STRING target, so normalize_column_types must run first.
89+
"""
90+
91+
def test_normalize_runs_before_upsert_with_float64_types(self):
92+
source_df = pl.DataFrame(
93+
[
94+
{
95+
"symbol": "AAPL",
96+
"company_name": "Apple",
97+
"report_date": "2026-07-15",
98+
"eps_estimated": "1.10",
99+
"eps_actual": None,
100+
"report_time": "amc",
101+
"timing": "after_market",
102+
}
103+
]
104+
)
105+
yahoo = MagicMock()
106+
yahoo.get_earnings_range.return_value = source_df
107+
108+
bq = MagicMock()
109+
call_order: list[str] = []
110+
bq.normalize_column_types.side_effect = lambda *a, **k: call_order.append(
111+
"normalize"
112+
)
113+
bq.upsert_data.side_effect = lambda *a, **k: call_order.append("upsert")
114+
115+
ctx = dg.build_asset_context()
116+
earnings_calendar(ctx, yahoo, bq)
117+
118+
# normalize must run, with FLOAT64 types, before the upsert.
119+
assert call_order == ["normalize", "upsert"]
120+
norm_args = bq.normalize_column_types.call_args
121+
assert norm_args.args[0] == "earnings_calendar"
122+
assert norm_args.args[1] == EARNINGS_NUMERIC_COLUMN_TYPES
123+
assert set(EARNINGS_NUMERIC_COLUMN_TYPES.values()) == {"FLOAT64"}
124+
125+
# The frame handed to upsert_data has FLOAT64 numeric columns.
126+
upsert_df = bq.upsert_data.call_args.args[1]
127+
for col in EARNINGS_NUMERIC_COLUMN_TYPES:
128+
assert upsert_df.schema[col] == pl.Float64

0 commit comments

Comments
 (0)