portfolio_stats
Return and risk statistics from a price or return table given inline as CSV (a date column and one column per asset): per asset and for a weighted portfolio rebalanced each period, total and annualised return, mean and annualised volatility on simple or log returns, Sharpe ratio over a risk-free rate, maximum drawdown with peak, trough and recovery dates, the correlation matrix and beta to a benchmark column.
Install
/tools install portfolio_stats /tools install portfolio_stats@1.0.0
In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · portfolio_stats 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool portfolio_stats; each run is approved as a local_exec.
Arguments
One JSON object on stdin, validated against the schema before the program starts.
| name | type | required | default | meaning |
|---|---|---|---|---|
csv | string | yes | Header row, then one row per date, oldest first | |
kind | string | no | "prices" | one of "prices", "returns" |
return_type | string | no | "simple" | one of "simple", "log" |
periods_per_year | number | no | 252 | |
risk_free_rate | number | string | no | 0 | Annual: 0.03 or "3%" |
weights | object of ['number', 'string'] | no | Column: weight; normalised to sum 1 | |
benchmark | string | no | Column to compute betas against | |
date_column | string | no | Default: the first column | |
delimiter | string | no | "," | length ≥ 1; length ≤ 1 |
Input schema (JSON)
{
"type": "object",
"required": [
"csv"
],
"additionalProperties": false,
"properties": {
"csv": {
"type": "string",
"description": "Header row, then one row per date, oldest first"
},
"kind": {
"type": "string",
"enum": [
"prices",
"returns"
],
"default": "prices"
},
"return_type": {
"type": "string",
"enum": [
"simple",
"log"
],
"default": "simple"
},
"periods_per_year": {
"type": "number",
"exclusiveMinimum": 0,
"default": 252
},
"risk_free_rate": {
"type": [
"number",
"string"
],
"default": 0,
"description": "Annual: 0.03 or \"3%\""
},
"weights": {
"type": "object",
"additionalProperties": {
"type": [
"number",
"string"
]
},
"description": "Column: weight; normalised to sum 1"
},
"benchmark": {
"type": "string",
"description": "Column to compute betas against"
},
"date_column": {
"type": "string",
"description": "Default: the first column"
},
"delimiter": {
"type": "string",
"minLength": 1,
"maxLength": 1,
"default": ","
}
}
}
Example
Input
{
"csv": "date,A,B\n2026-01-01,100,50\n2026-01-02,120,55\n2026-01-03,90,60.5\n2026-01-04,105,55\n2026-01-05,135,66\n",
"weights": {
"A": 1,
"B": 1
},
"benchmark": "B"
}
Output (stdout)
{
"assets": {
"A": {
"total_return": 0.35,
"max_drawdown": -0.25,
"peak": "2026-01-02",
"trough": "2026-01-03",
"recovery": "2026-01-05"
}
},
"portfolio": {
"total_return": 0.3721684253,
"max_drawdown": -0.075
},
"beta": {
"B": 1.0
}
}
Files
The command the install card registers is python3 portfolio_stats.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.
portfolio_stats.py · sha256 299c35d76400c05d8a4f400d3037c764a3702cf51d4202fbabbd10075166aafc
#!/usr/bin/env python3
"""Portfolio statistics from a price (or return) table given inline as CSV: per asset and for a
weighted portfolio rebalanced every period, the total and annualised return, mean and
volatility (annualised), Sharpe ratio over a risk-free rate, maximum drawdown with its peak,
trough and recovery dates; the correlation matrix and each series' beta to a benchmark column.
Input (JSON on stdin):
csv "date,AAA,BBB\n2026-01-02,100,50\n..." first column the date (or date_column)
kind "prices" (default) | "returns" (simple returns per period, as fractions)
return_type "simple" (default) | "log": the returns the mean, volatility, Sharpe,
correlation and beta are computed on
periods_per_year default 252 (12 monthly, 52 weekly)
risk_free_rate annual, default 0; per period it is rate / periods_per_year
weights {"AAA": 0.6, "BBB": 0.4} optional; normalised to sum 1
benchmark a column name to compute betas against (it stays out of the portfolio
unless weighted)
date_column name of the date column (default the first)
delimiter default ","
Rows are taken in the order given (oldest first).
"""
import csv
import io
import json
import math
import sys
from decimal import Decimal, InvalidOperation
PLACES = 10
def fail(message):
print(json.dumps({"error": message}))
sys.exit(2)
def num(value, field):
if isinstance(value, bool) or not isinstance(value, (int, float, str)):
fail(f"{field} must be a number or a decimal string")
text = str(value).strip()
scale = 1.0
if text.endswith("%"):
text, scale = text[:-1], 0.01
try:
number = float(Decimal(text)) * scale
except (InvalidOperation, ValueError):
fail(f"{field} must be a number or a decimal string")
if not math.isfinite(number):
fail(f"{field} must be finite")
return number
def r(x):
return None if x is None else round(x, PLACES)
def mean(xs):
return sum(xs) / len(xs)
def sample_cov(xs, ys):
if len(xs) < 2:
return None
mx, my = mean(xs), mean(ys)
return sum((a - mx) * (b - my) for a, b in zip(xs, ys)) / (len(xs) - 1)
def drawdown(wealth, dates):
"""wealth[0] is the starting value; dates[k] labels wealth[k]."""
peak_i, worst, worst_peak, worst_trough = 0, 0.0, None, None
for i, value in enumerate(wealth):
if value > wealth[peak_i]:
peak_i = i
dd = value / wealth[peak_i] - 1
if dd < worst:
worst, worst_peak, worst_trough = dd, peak_i, i
if worst_peak is None:
return {"max_drawdown": 0.0, "peak": None, "trough": None, "recovery": None}
recovery = next((dates[i] for i in range(worst_trough + 1, len(wealth)) if wealth[i] >= wealth[worst_peak]), None)
return {"max_drawdown": r(worst), "peak": dates[worst_peak], "trough": dates[worst_trough], "recovery": recovery}
def stats(simple, wealth, dates, log, ppy, rf):
returns = [math.log1p(x) for x in simple] if log else simple
n = len(simple)
total = wealth[-1] / wealth[0] - 1
out = {"periods": n, "total_return": r(total)}
out["annualised_return"] = r((1 + total) ** (ppy / n) - 1) if total > -1 else None
m = mean(returns)
out["mean_return"] = r(m)
out["annualised_mean_return"] = r(m * ppy)
variance = sample_cov(returns, returns)
sd = math.sqrt(variance) if variance is not None else None
out["volatility"] = r(sd)
out["annualised_volatility"] = r(sd * math.sqrt(ppy)) if sd is not None else None
rf_period = rf / ppy
out["sharpe_ratio"] = r((m - rf_period) / sd * math.sqrt(ppy)) if sd else None
out.update(drawdown(wealth, dates))
return out, returns
def main():
try:
request = json.load(sys.stdin)
except ValueError as error:
fail(f"stdin is not JSON: {error}")
if not isinstance(request, dict):
fail("input must be a JSON object")
known = {"csv", "kind", "return_type", "periods_per_year", "risk_free_rate", "weights", "benchmark", "date_column", "delimiter"}
extra = sorted(set(request) - known)
if extra:
fail(f"unknown field: {extra[0]}")
text = request.get("csv")
if not isinstance(text, str) or not text.strip():
fail("csv must be the table as text: a date column and one column per asset")
kind = request.get("kind", "prices")
if kind not in ("prices", "returns"):
fail("kind must be prices or returns")
return_type = request.get("return_type", "simple")
if return_type not in ("simple", "log"):
fail("return_type must be simple or log")
ppy = request.get("periods_per_year", 252)
if isinstance(ppy, bool) or not isinstance(ppy, (int, float)) or not 0 < ppy <= 100000:
fail("periods_per_year must be a positive number")
rf = num(request.get("risk_free_rate", 0), "risk_free_rate")
delimiter = request.get("delimiter", ",")
if not isinstance(delimiter, str) or len(delimiter) != 1:
fail("delimiter must be one character")
rows = [row for row in csv.reader(io.StringIO(text.strip()), delimiter=delimiter) if any(c.strip() for c in row)]
if len(rows) < 2:
fail("csv needs a header row and data rows")
header = [h.strip() for h in rows[0]]
if len(set(header)) != len(header):
fail("csv column names must be unique")
date_column = request.get("date_column", header[0])
if date_column not in header:
fail(f"date_column {date_column!r} is not a column")
d = header.index(date_column)
assets = [h for i, h in enumerate(header) if i != d]
if not assets:
fail("csv needs at least one asset column besides the date")
dates, columns = [], {a: [] for a in assets}
for line, row in enumerate(rows[1:], 2):
if len(row) != len(header):
fail(f"csv line {line} has {len(row)} cells, the header has {len(header)}")
dates.append(row[d].strip())
for i, h in enumerate(header):
if i == d:
continue
cell = row[i].strip()
try:
value = float(cell)
except ValueError:
fail(f"csv line {line}, column {h}: {cell!r} is not a number")
if not math.isfinite(value):
fail(f"csv line {line}, column {h}: not a finite number")
columns[h].append(value)
simple, wealth = {}, {}
if kind == "prices":
if len(dates) < 2:
fail("prices need at least two rows")
for a in assets:
p = columns[a]
if any(x <= 0 for x in p):
fail(f"column {a}: prices must be positive")
simple[a] = [p[k] / p[k - 1] - 1 for k in range(1, len(p))]
wealth[a] = [x / p[0] for x in p]
wealth_dates = dates
return_dates = dates[1:]
else:
for a in assets:
if any(x <= -1 for x in columns[a]):
fail(f"column {a}: a simple return of -100% or less")
simple[a] = columns[a]
w = [1.0]
for x in simple[a]:
w.append(w[-1] * (1 + x))
wealth[a] = w
wealth_dates = ["start"] + dates
return_dates = dates
log = return_type == "log"
result = {"kind": kind, "return_type": return_type, "periods_per_year": ppy, "risk_free_rate": rf,
"first_date": wealth_dates[0], "last_date": dates[-1], "assets": {}}
returns = {}
for a in assets:
result["assets"][a], returns[a] = stats(simple[a], wealth[a], wealth_dates, log, ppy, rf)
if "weights" in request:
raw = request["weights"]
if not isinstance(raw, dict) or not raw:
fail("weights must be an object of column: weight")
for a in raw:
if a not in assets:
fail(f"weights: {a!r} is not an asset column")
values = {a: num(v, f"weights[{a!r}]") for a, v in raw.items()}
total_weight = sum(values.values())
if total_weight <= 0:
fail("weights must add up to a positive number")
weights = {a: v / total_weight for a, v in values.items()}
port = [sum(weights[a] * simple[a][k] for a in weights) for k in range(len(return_dates))]
if any(x <= -1 for x in port):
fail("the portfolio loses 100% or more in a period")
w = [1.0]
for x in port:
w.append(w[-1] * (1 + x))
port_stats, returns["portfolio"] = stats(port, w, wealth_dates, log, ppy, rf)
port_stats["weights"] = {a: r(v) for a, v in weights.items()}
port_stats["rebalancing"] = "every period"
result["portfolio"] = port_stats
names = list(returns)
if len(return_dates) >= 2:
corr = {}
for a in names:
corr[a] = {}
for b in names:
cov = sample_cov(returns[a], returns[b])
va, vb = sample_cov(returns[a], returns[a]), sample_cov(returns[b], returns[b])
corr[a][b] = r(cov / math.sqrt(va * vb)) if va and vb else None
result["correlation"] = corr
if "benchmark" in request:
bench = request["benchmark"]
if bench not in assets:
fail(f"benchmark {bench!r} is not an asset column")
if len(return_dates) < 2:
fail("beta needs at least two returns")
vb = sample_cov(returns[bench], returns[bench])
result["benchmark"] = bench
result["beta"] = {a: (r(sample_cov(returns[a], returns[bench]) / vb) if vb else None) for a in names}
print(json.dumps(result))
if __name__ == "__main__":
try:
main()
except ArithmeticError:
fail("the numbers are out of the range this tool can compute")
Where it is served
The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/portfolio_stats/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/portfolio_stats/1.0.0/portfolio_stats.py