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
179 changes: 179 additions & 0 deletions alloc/models/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 --------------------------------------------------

Expand Down Expand Up @@ -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(
Expand Down
128 changes: 128 additions & 0 deletions tests/test_portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 59 additions & 0 deletions tickets/TICKET-045.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading