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
8 changes: 0 additions & 8 deletions application/account_facts_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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({
Expand Down
1 change: 1 addition & 0 deletions application/session_check_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
21 changes: 21 additions & 0 deletions docs/account-archive-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 26 additions & 11 deletions tests/test_account_facts_publisher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "])
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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"}]
45 changes: 45 additions & 0 deletions tests/test_cached_funds_archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"] == []
Loading