fix: convert Finnhub market cap from millions to absolute USD
Finnhub profile2 reports marketCapitalization in millions; storing it as dollars made mega-caps like SPCX show as micro (e.g. 1.8M). Normalize on ingest, add unit tests, and include a one-shot SQL backfill script.
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
"""Unit tests for FinnhubFundamentalProvider unit conversions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.providers.fundamentals_chain import FinnhubFundamentalProvider
|
||||
|
||||
|
||||
def _mock_response(status_code: int, json_data: object = None) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status_code=status_code,
|
||||
json=json_data if json_data is not None else {},
|
||||
request=httpx.Request("GET", "https://example.com"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider() -> FinnhubFundamentalProvider:
|
||||
return FinnhubFundamentalProvider(api_key="test-key")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finnhub_market_cap_converted_from_millions_to_dollars(provider):
|
||||
"""Finnhub marketCapitalization is in millions — store absolute USD.
|
||||
|
||||
SPCX-scale example: ~$1.8T → Finnhub reports 1_800_000 (millions).
|
||||
Without conversion the UI showed 1.8M / micro cap.
|
||||
"""
|
||||
profile = {"marketCapitalization": 1_800_000} # millions → $1.8T
|
||||
metrics = {"metric": {"peTTM": 40.0, "revenueGrowthTTMYoy": 25.0}}
|
||||
earnings = [{"surprisePercent": 2.5}]
|
||||
calendar = {"earningsCalendar": []}
|
||||
|
||||
async def mock_get(url, params=None):
|
||||
if "profile2" in url:
|
||||
return _mock_response(200, profile)
|
||||
if "stock/metric" in url:
|
||||
return _mock_response(200, metrics)
|
||||
if "stock/earnings" in url:
|
||||
return _mock_response(200, earnings)
|
||||
if "calendar/earnings" in url:
|
||||
return _mock_response(200, calendar)
|
||||
return _mock_response(200, {})
|
||||
|
||||
with patch("app.providers.fundamentals_chain.httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.get.side_effect = mock_get
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
MockClient.return_value = instance
|
||||
|
||||
result = await provider.fetch_fundamentals("SPCX")
|
||||
|
||||
assert result.market_cap == 1_800_000 * 1_000_000 # $1.8T
|
||||
assert result.pe_ratio == 40.0
|
||||
assert result.revenue_growth == 25.0
|
||||
assert result.earnings_surprise == 2.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_finnhub_market_cap_none_when_missing(provider):
|
||||
profile: dict = {}
|
||||
metrics = {"metric": {}}
|
||||
earnings: list = []
|
||||
calendar = {"earningsCalendar": []}
|
||||
|
||||
async def mock_get(url, params=None):
|
||||
if "profile2" in url:
|
||||
return _mock_response(200, profile)
|
||||
if "stock/metric" in url:
|
||||
return _mock_response(200, metrics)
|
||||
if "stock/earnings" in url:
|
||||
return _mock_response(200, earnings)
|
||||
if "calendar/earnings" in url:
|
||||
return _mock_response(200, calendar)
|
||||
return _mock_response(200, {})
|
||||
|
||||
with patch("app.providers.fundamentals_chain.httpx.AsyncClient") as MockClient:
|
||||
instance = AsyncMock()
|
||||
instance.get.side_effect = mock_get
|
||||
instance.__aenter__ = AsyncMock(return_value=instance)
|
||||
instance.__aexit__ = AsyncMock(return_value=False)
|
||||
MockClient.return_value = instance
|
||||
|
||||
result = await provider.fetch_fundamentals("XYZ")
|
||||
|
||||
assert result.market_cap is None
|
||||
assert "market_cap" in result.unavailable_fields
|
||||
Reference in New Issue
Block a user