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
40 changes: 29 additions & 11 deletions src/sources/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,18 @@ class BaseSource(ABC):
source_type: ClassVar[SourceType]
source_intro: ClassVar[str]
resolution_criteria: ClassVar[str]
nullified_questions: ClassVar[list[NullifiedQuestion]] = []
nullified_questions: ClassVar[list[NullifiedQuestion]]
resolution_schema: ClassVar[type] = ResolutionFrame

# Source-specific metadata keys this source needs to even run, beyond the globally required set.
# Declared on the concrete subclass (e.g. yfinance -> {"ticker_renames"}) so a missing/typo'd key
# in SOURCE_METADATA fails loudly at class-definition time rather than late at runtime.
additional_required_metadata_keys: ClassVar[set[str]] = set()

# Test-only escape hatch. Every production source must have a SOURCE_METADATA entry; test stubs
# that exercise BaseSource directly set this True to opt out of that requirement.
_allow_missing_metadata: ClassVar[bool] = False

def __init__(self) -> None:
"""Initialize with empty hash mapping."""
self.hash_mapping: dict[str, dict] = {}
Expand Down Expand Up @@ -67,17 +76,26 @@ def __init_subclass__(cls, **kwargs):
raise TypeError(f"Concrete source {cls.__name__} must define ClassVar '{attr}'")

# Auto-populate from metadata
_REQUIRED_METADATA_KEYS = {"source_type", "source_intro", "resolution_criteria"}
_REQUIRED_METADATA_KEYS = {
"source_type",
"source_intro",
"resolution_criteria",
"nullified_questions",
}
name = getattr(cls, "name", None)
if name and name in SOURCE_METADATA:
meta = SOURCE_METADATA[name]
missing = _REQUIRED_METADATA_KEYS - meta.keys()
if missing:
raise TypeError(
f"SOURCE_METADATA['{name}'] missing required keys: {sorted(missing)}"
)
for key, value in meta.items():
setattr(cls, key, value)
if name not in SOURCE_METADATA:
if cls._allow_missing_metadata:
return
raise TypeError(
f"Concrete source {cls.__name__} (name={name!r}) has no entry in SOURCE_METADATA. "
"Add one in sources/_metadata.py."
)
meta = SOURCE_METADATA[name]
missing = (_REQUIRED_METADATA_KEYS | cls.additional_required_metadata_keys) - meta.keys()
if missing:
raise TypeError(f"SOURCE_METADATA['{name}'] missing required keys: {sorted(missing)}")
for key, value in meta.items():
setattr(cls, key, value)

# ------------------------------------------------------------------
# Public resolve interface
Expand Down
5 changes: 5 additions & 0 deletions src/sources/_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"resolution_criteria": (
"Resolves to the value calculated from the ACLED dataset once the data is published."
),
"nullified_questions": [],
},
"dbnomics": {
"source_type": SourceType.DATASET,
Expand All @@ -32,6 +33,7 @@
"this data will resolve."
),
"resolution_criteria": "Resolves to the value found at {url} once the data is published.",
"nullified_questions": [],
},
"fred": {
"source_type": SourceType.DATASET,
Expand Down Expand Up @@ -61,6 +63,7 @@
"resolve as 'Yes'."
),
"resolution_criteria": "Resolves to the outcome of the question found at {url}.",
"nullified_questions": [],
},
"manifold": {
"source_type": SourceType.MARKET,
Expand All @@ -71,6 +74,7 @@
"resolve as 'Yes'."
),
"resolution_criteria": "Resolves to the outcome of the question found at {url}.",
"nullified_questions": [],
},
"metaculus": {
"source_type": SourceType.MARKET,
Expand All @@ -81,6 +85,7 @@
"resolve as 'Yes'."
),
"resolution_criteria": "Resolves to the outcome of the question found at {url}.",
"nullified_questions": [],
},
"polymarket": {
"source_type": SourceType.MARKET,
Expand Down
5 changes: 0 additions & 5 deletions src/sources/fred.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@

from typing import ClassVar

from _fb_types import NullifiedQuestion

from ._dataset import DatasetSource
from ._metadata import SOURCE_METADATA

Expand All @@ -20,9 +18,6 @@ class FredSource(DatasetSource):
"""Federal Reserve Economic Data source."""

name: ClassVar[str] = "fred"
nullified_questions: ClassVar[list[NullifiedQuestion]] = SOURCE_METADATA["fred"][
"nullified_questions"
]

def fetch(self, **kwargs):
"""Fetch FRED data from external API."""
Expand Down
1 change: 1 addition & 0 deletions src/sources/yfinance.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class YfinanceSource(DatasetSource):
"""Yahoo Finance financial data source."""

name: ClassVar[str] = "yfinance"
additional_required_metadata_keys: ClassVar[set[str]] = {"ticker_renames"}

# Pinned at the start of fetch()/update() so every downstream helper (via self.get_date_today())
# observes one consistent date for the whole run, even if it straddles midnight.
Expand Down
99 changes: 98 additions & 1 deletion src/tests/test_base_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from _fb_types import NullifiedQuestion, SourceType
from sources._base import BaseSource
from sources._metadata import SOURCE_METADATA
from tests.conftest import make_forecast_df, make_question_df

# ---------------------------------------------------------------------------
Expand All @@ -20,6 +21,8 @@ class _StubSource(BaseSource):

name = "stub"
source_type = SourceType.DATASET
nullified_questions = []
_allow_missing_metadata = True

def _resolve(self, df, dfq, dfr):
df["resolved_to"] = 1.0
Expand All @@ -38,6 +41,7 @@ class _StubSourceWithNullified(BaseSource):

name = "stub_null"
source_type = SourceType.DATASET
_allow_missing_metadata = True
nullified_questions = [
NullifiedQuestion(id="null_q1", nullification_start_date=date(2024, 6, 1)),
NullifiedQuestion(id="null_q2", nullification_start_date=date(2025, 1, 1)),
Expand Down Expand Up @@ -72,7 +76,18 @@ class _BadSource(BaseSource):
def _resolve(self, df, dfq, dfr):
return df

def test_valid_concrete_source_ok(self):
def test_valid_concrete_source_ok(self, monkeypatch):
monkeypatch.setitem(
SOURCE_METADATA,
"good",
{
"source_type": SourceType.MARKET,
"source_intro": "intro",
"resolution_criteria": "criteria",
"nullified_questions": [],
},
)

# Should not raise
class _GoodSource(BaseSource):
name = "good"
Expand All @@ -81,6 +96,88 @@ class _GoodSource(BaseSource):
def _resolve(self, df, dfq, dfr):
return df

def test_missing_metadata_entry_raises(self):
# A concrete source whose name has no SOURCE_METADATA entry (e.g. a typo) must fail at
# class-definition time rather than later at runtime.
with pytest.raises(TypeError, match="no entry in SOURCE_METADATA"):

class _NoMetadataSource(BaseSource):
name = "definitely_not_a_real_source"
source_type = SourceType.DATASET

def _resolve(self, df, dfq, dfr):
return df

def test_missing_required_nullified_questions_raises(self, monkeypatch):
# nullified_questions is globally required; a source whose metadata omits it must fail at
# class-definition time, not silently default to "nothing nullified".
monkeypatch.setitem(
SOURCE_METADATA,
"fake_no_nullified",
{
"source_type": SourceType.DATASET,
"source_intro": "intro",
"resolution_criteria": "criteria",
},
)
with pytest.raises(TypeError, match="nullified_questions"):

class _BadNullified(BaseSource):
name = "fake_no_nullified"
source_type = SourceType.DATASET

def _resolve(self, df, dfq, dfr):
return df

def test_missing_source_specific_required_key_raises(self, monkeypatch):
# A source-specific key declared via additional_required_metadata_keys but absent from SOURCE_METADATA
# must fail loudly at class-definition time.
monkeypatch.setitem(
SOURCE_METADATA,
"fake_missing_specific",
{
"source_type": SourceType.DATASET,
"source_intro": "intro",
"resolution_criteria": "criteria",
"nullified_questions": [],
},
)
with pytest.raises(TypeError, match="ticker_renames"):

class _BadSpecific(BaseSource):
name = "fake_missing_specific"
source_type = SourceType.DATASET
additional_required_metadata_keys = {"ticker_renames"}

def _resolve(self, df, dfq, dfr):
return df

def test_additional_required_metadata_keys_satisfied_populates(self, monkeypatch):
# When a declared source-specific key is present, no raise and it's set on the class.
renames = [{"original_ticker": "FI", "replacement_ticker": "FISV"}]
monkeypatch.setitem(
SOURCE_METADATA,
"fake_with_specific",
{
"source_type": SourceType.DATASET,
"source_intro": "intro",
"resolution_criteria": "criteria",
"nullified_questions": [],
"ticker_renames": renames,
},
)

class _GoodSpecific(BaseSource):
name = "fake_with_specific"
source_type = SourceType.DATASET
additional_required_metadata_keys = {"ticker_renames"}

def _resolve(self, df, dfq, dfr):
return df

assert _GoodSpecific.ticker_renames == renames
assert _GoodSpecific.nullified_questions == []


# ---------------------------------------------------------------------------
# _is_combo
Expand Down
Loading