diff --git a/alloc/models/portfolio.py b/alloc/models/portfolio.py index b6ee6ae..95d4e69 100644 --- a/alloc/models/portfolio.py +++ b/alloc/models/portfolio.py @@ -40,6 +40,8 @@ def __init__( self.cash = float(initial_cash) self.transaction_cost = float(transaction_cost) self.shares_held: dict[str, float] = {t: 0.0 for t in self.tickers} + # Value history: one entry per recorded valuation (seeded with initial cash). + self.portfolio_values: list[float] = [float(initial_cash)] # -- valuation -------------------------------------------------- @@ -196,6 +198,183 @@ def execute_trades( } + # -- value history ---------------------------------------------- + + def record_value(self, prices: dict[str, float]) -> float: + """Append the current portfolio value to :attr:`portfolio_values`. + + Call this once per valuation step (e.g. after each day's trades) + so that :meth:`calculate_returns` has a series to analyse. + + Parameters + ---------- + prices : dict + Current price per ticker. + + Returns + ------- + float + The value that was recorded. + """ + value = self.get_portfolio_value(prices) + self.portfolio_values.append(value) + return value + + # -- returns analysis ------------------------------------------- + + def calculate_returns(self, lookback: int | None = None) -> dict[str, Any]: + """Compute return metrics from :attr:`portfolio_values`. + + Metrics + ------- + * ``daily_returns`` — per-period simple returns (length = n-1). + * ``cumulative_return`` — total growth over the window. + * ``annualized_return`` — geometric annualisation at 252 periods/yr. + * ``sharpe_ratio`` — annualised, risk-free rate 2% (daily rf = 0.02/252). + * ``max_drawdown`` — largest peak-to-trough decline (fraction, ≥ 0). + + Parameters + ---------- + lookback : int, optional + If given, only the trailing *lookback* values are used. + + Returns + ------- + dict + The metrics above. With fewer than two recorded values every + metric is returned at its neutral value (empty list / 0.0). + """ + neutral = { + "daily_returns": [], + "cumulative_return": 0.0, + "annualized_return": 0.0, + "sharpe_ratio": 0.0, + "max_drawdown": 0.0, + } + if len(self.portfolio_values) < 2: + return neutral + + values = self.portfolio_values + if lookback is not None and lookback < len(values): + values = values[-lookback:] + + daily_returns = [ + (values[i] / values[i - 1]) - 1.0 + for i in range(1, len(values)) + if values[i - 1] > 0 + ] + + cumulative_return = (values[-1] / values[0]) - 1.0 if values[0] > 0 else 0.0 + + # Annualised return (geometric, 252 periods/yr) + if daily_returns: + avg_daily = sum(daily_returns) / len(daily_returns) + annualized_return = (1.0 + avg_daily) ** 252 - 1.0 + else: + avg_daily = 0.0 + annualized_return = 0.0 + + # Sharpe ratio (rf = 2% annual) + if len(daily_returns) > 1: + daily_std = float(np.std(daily_returns)) + rf_daily = 0.02 / 252.0 + sharpe_ratio = ( + ((avg_daily - rf_daily) / daily_std) * (252.0 ** 0.5) + if daily_std > 0 + else 0.0 + ) + else: + sharpe_ratio = 0.0 + + # Maximum drawdown + peak = values[0] + max_drawdown = 0.0 + for value in values: + peak = max(peak, value) + if peak > 0: + dd = (peak - value) / peak + max_drawdown = max(max_drawdown, dd) + + return { + "daily_returns": daily_returns, + "cumulative_return": cumulative_return, + "annualized_return": annualized_return, + "sharpe_ratio": sharpe_ratio, + "max_drawdown": max_drawdown, + } + + # -- portfolio statistics --------------------------------------- + + def calculate_portfolio_statistics(self, prices: dict[str, float]) -> dict[str, Any]: + """Report per-position detail and a concentration summary. + + Parameters + ---------- + prices : dict + Current price per ticker. + + Returns + ------- + dict + ``portfolio_value``, ``cash``, ``cash_allocation``, ``positions`` + (per-ticker shares/price/value/allocation), and ``concentration`` + with the Herfindahl-Hirschman index (raw + normalised) and the + number of assets held. A ``returns`` block is included when at + least two values have been recorded. + """ + portfolio_value = self.get_portfolio_value(prices) + allocation = self.get_allocation(prices) + + positions: dict[str, dict[str, float]] = {} + for t in self.tickers: + if t in prices: + positions[t] = { + "shares": self.shares_held.get(t, 0.0), + "price": prices[t], + "value": self.shares_held.get(t, 0.0) * prices[t], + "allocation": allocation.get(t, 0.0), + } + + # HHI over non-cash positions with a positive allocation. + # The non-cash weights are renormalised to sum to 1 so the index + # is well-defined regardless of the cash position: HHI then lies in + # [1/n, 1] and the normalised form in [0, 1] (0 = equal, 1 = single). + non_cash = [ + allocation[t] + for t in self.tickers + if allocation.get(t, 0.0) > 0 + ] + if non_cash: + total_nc = sum(non_cash) + weights = [w / total_nc for w in non_cash] if total_nc > 0 else [] + hhi = float(np.sum(np.square(weights))) + n = len(weights) + if n > 1: + hhi_normalized = (hhi - (1.0 / n)) / (1.0 - (1.0 / n)) + else: + hhi_normalized = 1.0 + else: + hhi = 0.0 + hhi_normalized = 0.0 + + stats: dict[str, Any] = { + "portfolio_value": portfolio_value, + "cash": self.cash, + "cash_allocation": allocation.get("cash", 0.0), + "positions": positions, + "concentration": { + "hhi": hhi, + "hhi_normalized": hhi_normalized, + "num_assets_held": len(non_cash), + }, + } + + if len(self.portfolio_values) > 1: + stats["returns"] = self.calculate_returns() + + return stats + + # ── Reward ────────────────────────────────────────────────────────── def calculate_portfolio_reward( diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index c6c7656..11938f4 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -424,3 +424,131 @@ def test_zero_allocation_no_crash(self): concentration_penalty=0.02, ) assert not math.isnan(reward) + + +# ── Value history + returns analysis (seed-parity) ───────────────── + +class TestValueHistory: + def test_seeded_with_initial_cash(self): + p = Portfolio(tickers=TICKERS, initial_cash=50_000.0) + assert p.portfolio_values == [50_000.0] + + def test_record_value_appends(self, portfolio, prices): + portfolio.shares_held["AAPL"] = 100 + v = portfolio.record_value(prices) + assert v == pytest.approx(INITIAL_CASH + 100 * 150.0) + assert len(portfolio.portfolio_values) == 2 + assert portfolio.portfolio_values[-1] == pytest.approx(v) + + +class TestCalculateReturns: + def test_neutral_when_insufficient_history(self, portfolio): + r = portfolio.calculate_returns() + assert r["daily_returns"] == [] + assert r["cumulative_return"] == 0.0 + assert r["sharpe_ratio"] == 0.0 + assert r["max_drawdown"] == 0.0 + + def test_cumulative_and_daily(self, portfolio, prices): + portfolio.shares_held["AAPL"] = 100 + portfolio.record_value({"AAPL": 150.0, "GOOGL": 2800.0, "MSFT": 300.0}) + portfolio.record_value({"AAPL": 160.0, "GOOGL": 2800.0, "MSFT": 300.0}) + r = portfolio.calculate_returns() + # history: [100000, 115000, 116000] -> 2 daily returns + assert len(r["daily_returns"]) == 2 + assert r["daily_returns"][0] == pytest.approx(115000.0 / 100000.0 - 1.0) + assert r["daily_returns"][1] == pytest.approx(116000.0 / 115000.0 - 1.0) + assert r["cumulative_return"] == pytest.approx(116000.0 / 100000.0 - 1.0) + + def test_max_drawdown(self, portfolio): + # Build a value path with a known drawdown: 100 -> 120 -> 90 -> 110 + portfolio.portfolio_values = [100.0, 120.0, 90.0, 110.0] + r = portfolio.calculate_returns() + # peak 120 -> trough 90 = 25% drawdown + assert r["max_drawdown"] == pytest.approx(0.25) + + def test_lookback_limits_window(self, portfolio): + portfolio.portfolio_values = [100.0, 110.0, 121.0, 133.1] + full = portfolio.calculate_returns() + limited = portfolio.calculate_returns(lookback=2) + assert len(full["daily_returns"]) == 3 + assert len(limited["daily_returns"]) == 1 + # last step: 133.1/121 - 1 + assert limited["daily_returns"][0] == pytest.approx(133.1 / 121.0 - 1.0) + + def test_sharpe_positive_for_rising_series(self, portfolio): + # steadily rising values -> positive mean return -> positive Sharpe + portfolio.portfolio_values = [100.0, 101.0, 102.0, 103.0, 104.0, 105.0] + r = portfolio.calculate_returns() + assert r["sharpe_ratio"] > 0 + + def test_annualized_return_present(self, portfolio): + portfolio.portfolio_values = [100.0, 110.0, 121.0] + r = portfolio.calculate_returns() + assert isinstance(r["annualized_return"], float) + + +# ── Portfolio statistics (seed-parity) ───────────────────────────── + +class TestPortfolioStatistics: + def test_positions_reported(self, portfolio, prices): + portfolio.shares_held["AAPL"] = 100 + stats = portfolio.calculate_portfolio_statistics(prices) + pos = stats["positions"]["AAPL"] + assert pos["shares"] == 100 + assert pos["price"] == pytest.approx(150.0) + assert pos["value"] == pytest.approx(15000.0) + assert pos["allocation"] == pytest.approx(15000.0 / stats["portfolio_value"]) + + def test_concentration_single_asset(self, portfolio, prices): + portfolio.shares_held["AAPL"] = 100 + stats = portfolio.calculate_portfolio_statistics(prices) + c = stats["concentration"] + assert c["num_assets_held"] == 1 + # single asset -> normalized HHI = 1.0 + assert c["hhi_normalized"] == pytest.approx(1.0) + + def test_concentration_equal_assets(self, portfolio, prices): + # equal dollar positions -> minimal HHI + portfolio.shares_held["AAPL"] = 100 # 15000 + portfolio.shares_held["GOOGL"] = 15000.0 / 2800.0 # 15000 + portfolio.shares_held["MSFT"] = 15000.0 / 300.0 # 15000 + stats = portfolio.calculate_portfolio_statistics(prices) + c = stats["concentration"] + assert c["num_assets_held"] == 3 + # non-cash weights renormalised to sum to 1 -> equal 1/3 each + assert c["hhi"] == pytest.approx(1.0 / 3.0) + # equal weights -> minimal HHI -> normalized ~ 0.0 + assert c["hhi_normalized"] == pytest.approx(0.0, abs=1e-9) + + def test_no_returns_block_without_history(self, portfolio, prices): + stats = portfolio.calculate_portfolio_statistics(prices) + assert "returns" not in stats + + def test_returns_block_with_history(self, portfolio, prices): + portfolio.record_value(prices) + stats = portfolio.calculate_portfolio_statistics(prices) + assert "returns" in stats + + +# ── alloc.__main__ entry point (issue #101) ──────────────────────── + +class TestMainEntryPoint: + """Complementary coverage for the ``python -m alloc`` entry point. + + Note: the "zero coverage" claim in issue #101 is stale — ``__main__`` + is already exercised by ``tests/test_cli.py::TestMainModule`` and + ``tests/test_actor_critic.py::TestMainModule``. These tests add a + behavioural check that the entry point delegates to ``alloc.cli.main`` + and propagates its exit code. + """ + + def test_main_is_cli_main(self): + from alloc.__main__ import main as entry + from alloc.cli import main as cli_main + assert entry is cli_main + + def test_main_propagates_exit_code(self): + from alloc.__main__ import main + # --help short-circuits with exit code 0 + assert main(["--help"]) == 0 diff --git a/tickets/TICKET-045.md b/tickets/TICKET-045.md new file mode 100644 index 0000000..4851629 --- /dev/null +++ b/tickets/TICKET-045.md @@ -0,0 +1,59 @@ +# TICKET-045: Wire Portfolio value-history into SimulationRunner; retire ad-hoc Sharpe/ROI + +**Status:** OPEN +**Date:** 2025-08-16 +**Cycle:** 36 +**Priority:** Medium +**Issue:** https://github.com/belarusian/alloc/issues/111 + +## Summary + +`alloc/models/portfolio.py` now tracks `portfolio_values` and exposes +`calculate_returns()` (daily / cumulative / annualized / Sharpe / max-drawdown) +and `calculate_portfolio_statistics()`. However, `SimulationRunner` in +`alloc/core.py` never calls `record_value()`, so the history stays at its +single seeded entry and the new methods are dead code in the live path. +Meanwhile `core.py` still computes Sharpe and ROI ad-hoc. + +## Evidence + +- `alloc/models/portfolio.py` — `Portfolio.__init__` seeds + `self.portfolio_values = [float(initial_cash)]`; `record_value()` appends. +- `alloc/core.py` — `SimulationRunner.run()` builds `portfolio_values` as a + local list (returned in `results["portfolio_values"]`) but never invokes + `portfolio.record_value(...)`. +- `alloc/core.py` — the `_trainer` closure recomputes Sharpe inline: + `daily_returns = np.diff(values) / np.maximum(values[:-1], 1e-8)` and + `sharpe_ratio = mean/std * sqrt(252)`, and ROI as + `(final_value - initial_value) / initial_value * 100`. These duplicate the + logic now centralised in `Portfolio.calculate_returns()`. + +## Impact + +- The seed-parity capability is present but unused in the production loop. +- Two divergent Sharpe implementations (core.py ad-hoc vs. Portfolio method) + risk drifting apart; core.py's version uses `np.diff/np.maximum` while the + Portfolio method uses a guard on `values[i-1] > 0`. +- No max-drawdown or annualized-return is surfaced in results today. + +## Suggestion (implementation plan) + +1. In `SimulationRunner.run()`, after each day's trades execute, call + `portfolio.record_value(prices)` so `portfolio.portfolio_values` mirrors the + per-day valuation series. +2. Replace the ad-hoc Sharpe/ROI block in `_trainer` with a call to + `portfolio.calculate_returns()`; map its keys onto the existing result + fields (`sharpe_ratio`, `model_roi` from `cumulative_return * 100`). +3. Add `max_drawdown` and `annualized_return` to the returned results dict. +4. Keep the `np.maximum(values[:-1], 1e-8)` guard semantics or reconcile with + the Portfolio method's `> 0` guard — pick one and document it. +5. Add a test in `tests/test_core.py` asserting `results["portfolio_values"]` + length equals `trading_days + 1` and that `sharpe_ratio` matches + `portfolio.calculate_returns()["sharpe_ratio"]`. + +## Acceptance criteria + +- `SimulationRunner` populates `portfolio.portfolio_values` via `record_value`. +- Sharpe/ROI in results are derived from `Portfolio.calculate_returns()`. +- `max_drawdown` and `annualized_return` present in results. +- Full test suite green. diff --git a/tickets/TICKET-046.md b/tickets/TICKET-046.md new file mode 100644 index 0000000..a159240 --- /dev/null +++ b/tickets/TICKET-046.md @@ -0,0 +1,52 @@ +# TICKET-046: HHI normalization diverges from seed — document + regression-guard + +**Status:** OPEN +**Date:** 2025-08-16 +**Cycle:** 36 +**Priority:** Low +**Issue:** https://github.com/belarusian/alloc/issues/112 + +## Summary + +The seed's `calculate_portfolio_statistics` computes HHI directly over the +non-cash allocation fractions. Because those fractions sum to < 1 whenever +cash is held, the seed's "normalized" HHI +`(hhi - 1/n) / (1 - 1/n)` can go **negative** (observed -0.45 in a 3-asset, +cash-heavy portfolio), which is outside the documented [0, 1] range. + +alloc's implementation deliberately renormalizes the non-cash weights to sum +to 1 before squaring, so HHI ∈ [1/n, 1] and the normalized form ∈ [0, 1]. +This is an intentional improvement over the seed, but it means alloc's HHI +values are **not numerically equal** to the seed's for the same portfolio. + +## Evidence + +- Seed `trader/models/portfolio.py` `calculate_portfolio_statistics` (line + ~648): `hhi = sum(np.square(non_cash_allocations))` where + `non_cash_allocations` are raw allocation fractions (sum < 1 with cash). +- alloc `alloc/models/portfolio.py` `calculate_portfolio_statistics`: + `weights = [w / total_nc for w in non_cash]` then `hhi = sum(square(weights))`. +- Reproduced: 3 equal non-cash positions + large cash → seed normalized HHI + ≈ -0.45; alloc normalized HHI ≈ 0.0. + +## Impact + +- Any downstream consumer expecting seed-identical HHI numbers will see a + difference. This is a parity gap by design, not a bug, but it must be + documented so it is not "fixed" back into the negative range. + +## Suggestion (implementation plan) + +1. Add a module-level note in `alloc/models/portfolio.py` (or docs/) stating + that HHI is computed on renormalized non-cash weights and therefore + differs from the seed's raw-fraction HHI. +2. Add a regression test asserting `hhi_normalized` stays within [0, 1] for a + cash-heavy portfolio (guard against reintroducing the negative range). +3. If strict seed parity is ever required, add an optional + `renormalize: bool = True` parameter to + `calculate_portfolio_statistics` and document the trade-off. + +## Acceptance criteria + +- Documented divergence between alloc and seed HHI semantics. +- Regression test pins `hhi_normalized ∈ [0, 1]` for cash-heavy portfolios. diff --git a/tickets/TICKET-047.md b/tickets/TICKET-047.md new file mode 100644 index 0000000..c0a991a --- /dev/null +++ b/tickets/TICKET-047.md @@ -0,0 +1,51 @@ +# TICKET-047: Issue #101 "alloc.__main__ zero coverage" is stale — consolidate tests + +**Status:** OPEN +**Date:** 2025-08-16 +**Cycle:** 36 +**Priority:** Low +**Issue:** https://github.com/belarusian/alloc/issues/113 + +## Summary + +Issue #101 claims `alloc/__main__.py` has zero test coverage. This is no +longer true: `__main__` is exercised by **two** test classes, and a third +was added this cycle. The issue should be closed and the duplicate coverage +consolidated. + +## Evidence + +- `tests/test_cli.py::TestMainModule` (line ~803) — 2 tests (import, has main). +- `tests/test_actor_critic.py::TestMainModule` (line ~505, tagged TICKET-034) + — 7 tests (import, delegation, callable, exit code, invalid args, AST + guard check, runpy invocation). +- `tests/test_portfolio.py::TestMainEntryPoint` (added this cycle) — 2 tests + (delegation identity, exit-code propagation). +- All pass: `pytest tests/test_cli.py::TestMainModule + tests/test_actor_critic.py::TestMainModule tests/test_portfolio.py::TestMainEntryPoint` + → 11 passed. + +## Impact + +- Issue #101 is misleading; a newcomer would believe the entry point is + untested and may add redundant tests. +- Three near-duplicate `TestMainModule`/`TestMainEntryPoint` classes scatter + the same concerns across three files. + +## Suggestion (implementation plan) + +1. Close issue #101 with a note that coverage now exists (cite the three + classes). +2. Consolidate the `__main__` tests into a single `tests/test_main.py` + (or keep them in `test_cli.py` since `__main__` delegates to `cli.main`), + removing the duplicates in `test_actor_critic.py` and + `test_portfolio.py`. +3. Keep the strongest assertions: delegation identity + (`alloc.__main__.main is alloc.cli.main`), exit-code propagation, and the + runpy `python -m alloc --help` invocation. + +## Acceptance criteria + +- Issue #101 closed. +- A single canonical test location for `alloc.__main__`. +- No loss of the delegation / exit-code / runpy assertions.