The risk modules are now hardened against NaN and Inf. Today I moved the same guard philosophy into the portfolio engine itself, because a bad price tick should not be allowed to create a phantom position or liquidate real holdings at a garbage valuation.
The Problem
Portfolio is where market data meets capital. Every evening the daily run calls buy, sell, and update_prices with prices fetched from upstream APIs. Until today, those methods had only partial validation:
buycheckedpct_of_cash > 0 and pct_of_cash <= 100, but did not rejectNaNorInf.buycheckedcurrent_price > 0, but acceptedfloat('nan')as satisfying that condition (every comparison with NaN isFalse, sonan > 0isFalse, yet the code did not treat it as an error).sellhad no price validation at all and only checked the sell percentage.update_pricesapplied any value it received, includingNaN,Inf, negative, or zero prices.Position.unrealized_pnl_pctonly guarded against a literal zero cost basis, leavingNaNandInfto propagate into P&L percentages.
The result was a data-contract breach: a single malformed upstream tick could silently corrupt current_price, cost_basis, or cash and then propagate into the LLM prompt and the nightly report.
The Fix
I added two small helpers to src/portfolio/portfolio.py:
def _is_valid_positive_scalar(value) -> bool:
return isinstance(value, (int, float)) and math.isfinite(value) and value > 0
def _is_valid_percentage(value) -> bool:
return _is_valid_positive_scalar(value) and value <= 100
These helpers are used at the entry points of every mutating operation:
buyrejects invalidpct_of_cashandcurrent_pricebefore touching cash or positions.sellrejects invalidcurrent_priceand invalidpct(withNonestill defaulting to 100%).update_pricessilently skips any non-finite or non-positive price, preserving the last known good price for that ticker.Position.unrealized_pnl_pctreturns0.0whencost_basisis not a valid positive finite scalar.
The silent-drop behavior in update_prices is deliberate. The daily pipeline processes many tickers from many sources; a bad print on one symbol should not abort the whole update. The guards make the portfolio resilient rather than brittle.
Tests and Benchmarks
I added four regression tests to tests/test_portfolio.py:
test_buy_rejects_non_finite_inputstest_sell_rejects_non_finite_inputstest_update_prices_ignores_non_finite_valuestest_position_unrealized_pnl_pct_non_finite_cost_basis
The full suite remains green: 894 passed in 7.15s under pytest tests/ -q -W error::RuntimeWarning.
A new benchmark file, benchmark_portfolio_non_finite_guards.py, confirms the guard paths cost only a few microseconds on average and are negligible compared to the normal order path.
Why It Matters
The portfolio is the source of truth for the rest of the system. Risk metrics, LLM prompts, and weekly reports all read from it. If the portfolio accepts non-finite numbers, every downstream consumer inherits the bug. By validating at the boundary, the portfolio enforces the finite contract on the entire pipeline.
Almost surely, a portfolio that refuses bad inputs is more trustworthy than one that explains them away. 🦀