diff --git a/application/account_facts_publisher.py b/application/account_facts_publisher.py index 1d99102..ce91173 100644 --- a/application/account_facts_publisher.py +++ b/application/account_facts_publisher.py @@ -216,10 +216,6 @@ def build_firstrade_account_snapshot( if not isinstance(balances, Mapping): raise AccountFactsPublishError("snapshot_invalid") currency = balances.get("currency") - # Firstrade US equity balance payloads often omit an ISO currency field; the - # rest of this platform already treats Firstrade cash as USD. - if currency is None: - currency = "USD" if not isinstance(currency, str) or not _CURRENCY_RE.fullmatch(currency): raise AccountFactsPublishError("provider_currency_unavailable") provider_equity = balances.get("provider_equity") @@ -232,10 +228,6 @@ def build_firstrade_account_snapshot( raise AccountFactsPublishError("snapshot_time_invalid") cash_balance = balances.get("cash_balance") - # Use exact available_cash only when cash_balance is absent — still a real - # provider cash figure, never synthesized from equity or buying power. - if cash_balance is None: - cash_balance = balances.get("available_cash") cash: list[dict[str, str]] = [] if cash_balance is not None: cash.append({ diff --git a/application/session_check_service.py b/application/session_check_service.py index 549718d..aac53f7 100644 --- a/application/session_check_service.py +++ b/application/session_check_service.py @@ -388,6 +388,7 @@ def archive_cached_account_facts( "source": "cached_account_facts", "balance_metrics": { "total_equity": balances.get("provider_equity"), + "currency": balances.get("currency"), "cash_balance": balances.get("cash_balance"), "available_cash": balances.get("available_cash"), "buying_power": balances.get("buying_power"), diff --git a/docs/account-archive-history.md b/docs/account-archive-history.md index a81081a..170c42e 100644 --- a/docs/account-archive-history.md +++ b/docs/account-archive-history.md @@ -127,6 +127,27 @@ unchanged. Existing conflicting records are rejected; unknown POST outcomes are not automatically retried. A successful exact history-only ACK is not a logged-in API or page acceptance result. +## Cached observation cash and currency + +For forward observations from 2026-11-03, the cached account-facts publisher +uses only exact `balances.cash_balance` for `cash[].cash_balance`, tagged +`provider.cash_balance`. Missing exact cash produces an empty `cash` list; +`available_cash` and `buying_power` never replace it. Decimal text, including +zero and negative cash, is preserved without conversion. + +Published balance and cash rows require an explicit native `balances.currency`. +Missing or invalid currency fails with `provider_currency_unavailable` before +archive or publication; neither USD nor configured position currency supplies +that native fact. The existing payload schema is unchanged. + +The cached archive independently retains `balance_metrics.currency`, including +JSON null when the native currency is absent. Exact/null cash, available cash +and buying power remain separate fields in both existing latest and history +records. Original observation times and storage paths are unchanged. This is +an additive archive field for new writes, with no history backfill or rewrite. +The two archive writes remain non-atomic. Older saved-history projection above +is a separate route and retains its existing currency policy. + ## Cached account-history source inspection Cached-only account facts and history reads use the shared session cache as the diff --git a/tests/test_account_facts_publisher.py b/tests/test_account_facts_publisher.py index c3a1691..34a838d 100644 --- a/tests/test_account_facts_publisher.py +++ b/tests/test_account_facts_publisher.py @@ -73,13 +73,15 @@ def test_builds_fixed_snapshot_with_only_provider_equity_and_cash(): assert "buying_power" not in repr(payload) -def test_defaults_missing_provider_currency_to_usd(): +@pytest.mark.parametrize("missing_key", [False, True]) +def test_rejects_missing_native_currency_even_with_configured_position_currency(missing_key): data = observation() data["balances"]["currency"] = None - payload = build_firstrade_account_snapshot( - data, load_account_facts_publish_config(config()), account_scope="us" - ) - assert payload["broker_reported_balances"][0]["currency"] == "USD" + if missing_key: + del data["balances"]["currency"] + cfg = load_account_facts_publish_config(config(FIRSTRADE_ACCOUNT_FACTS_POSITION_CURRENCY="EUR")) + with pytest.raises(AccountFactsPublishError, match="^provider_currency_unavailable$"): + build_firstrade_account_snapshot(data, cfg, account_scope="us") @pytest.mark.parametrize("value", ["", "usd", "US", "USDX", "USD "]) @@ -125,20 +127,20 @@ def test_allows_cash_to_be_absent_without_synthesizing_it(): assert payload["cash"] == [] -def test_falls_back_to_available_cash_when_cash_balance_absent(): +@pytest.mark.parametrize("missing_key", [False, True]) +def test_available_cash_never_replaces_missing_exact_cash(missing_key): data = observation() data["balances"]["cash_balance"] = None data["balances"]["available_cash"] = "240.00" + if missing_key: + del data["balances"]["cash_balance"] payload = build_firstrade_account_snapshot( data, load_account_facts_publish_config(config()), account_scope="us" ) - assert payload["cash"] == [{ - "currency": "USD", - "cash_balance": "240.00", - "source_tag": "provider.cash_balance", - }] + assert payload["cash"] == [] + assert payload["broker_reported_balances"] == [{"currency": "USD", "net_assets": "1234.50"}] @pytest.mark.parametrize( @@ -383,3 +385,16 @@ def test_opt_in_reports_ambiguous_long_stock_value_distinctly(): with pytest.raises(AccountFactsPublishError, match="long_stock_value_ambiguous"): build_firstrade_account_snapshot( data, cfg, account_scope="us", include_valuation_basis=True) + + +@pytest.mark.parametrize("cash_balance", ["0.00", "-12.34567890", "250.25000000"]) +def test_preserves_exact_cash_and_native_currency_separately(cash_balance): + data = observation() + data["balances"].update(currency="EUR", cash_balance=cash_balance) + cfg = load_account_facts_publish_config(config(FIRSTRADE_ACCOUNT_FACTS_POSITION_CURRENCY="USD")) + data.update(positions_status="available", positions=[]) + payload = build_firstrade_account_snapshot(data, cfg, account_scope="us") + assert payload["cash"] == [{ + "currency": "EUR", "cash_balance": cash_balance, "source_tag": "provider.cash_balance", + }] + assert payload["broker_reported_balances"] == [{"currency": "EUR", "net_assets": "1234.50"}] diff --git a/tests/test_cached_funds_archive.py b/tests/test_cached_funds_archive.py index a7f5c90..2d6d73c 100644 --- a/tests/test_cached_funds_archive.py +++ b/tests/test_cached_funds_archive.py @@ -109,3 +109,48 @@ def test_unrequested_positions_stay_absent(archive_route): observation.update(positions_status="not_requested", positions=None) assert invoke().status_code == 200 assert "positions" not in writes[1][1] + + +@pytest.mark.parametrize("currency", ["USD", "EUR", None, "missing"]) +def test_cached_archive_retains_native_currency_in_both_json_records(archive_route, currency): + import json + + observation, writes, publishes, _, service = archive_route + observation["observed_started_at"] = "2026-11-03T23:59:59+00:00" + observation["observed_finished_at"] = "2026-11-04T00:00:03+00:00" + observation["balances"].update(currency=currency, cash_balance="0.00000000") + if currency == "missing": + del observation["balances"]["currency"] + expected = observation["balances"].get("currency") + # Exercise the archive independently: unknown currency is retained as null, + # even though the publisher rejects it before the endpoint archives anything. + service.archive_cached_account_facts(observation) + assert len(writes) == 2 + for key, value in writes: + saved = json.loads(json.dumps(value)) + assert saved["balance_metrics"] == { + "total_equity": "100.123456", "cash_balance": "0.00000000", + "available_cash": "25.01", "buying_power": None, "currency": expected, + } + assert saved["as_of"] == observation["observed_started_at"] + assert saved["observed_finished_at"] == observation["observed_finished_at"] + assert writes[0][0].endswith("/funds/latest.json") + assert writes[1][0].endswith("/funds/history/2026/11/03/20261103T235959Z.json") + assert publishes == [] + + +@pytest.mark.parametrize("currency", [None, "", "usd"]) +def test_unknown_or_invalid_currency_blocks_archive_and_publish(archive_route, currency): + observation, writes, publishes, client, _ = archive_route + observation["balances"]["currency"] = currency + response = invoke() + assert response.status_code == 503 + assert response.json == {"ok": False, "error": "provider_currency_unavailable"} + assert writes == publishes == [] + assert client.closed + + +def test_endpoint_does_not_publish_available_cash_as_exact_cash(archive_route): + _, _, publishes, _, _ = archive_route + assert invoke().status_code == 200 + assert publishes[0][0]["cash"] == []