Reports¶
py-beacon writes two kinds of report:
- PDF documents (
beacon.report): a page laid out from blocks, for reading as it was designed, such as a factsheet. - Excel workbooks (
beacon.portfolio.reporting): holdings and performance tables, for someone to pick up and work with.
Building a PDF template needs nothing extra. Rendering it needs the pdf
extra (reportlab), and Excel output needs the excel extra (openpyxl):
A template is data¶
A ReportTemplate is a page setup and an ordered list of blocks. The blocks
are plain frozen dataclasses in beacon.report.blocks (also importable from
beacon.report). They know nothing about drawing, so a template can be built,
saved as JSON and edited without reportlab installed.
| Block | Draws |
|---|---|
Header(title, subtitle="", as_of="") |
A title band with a rule beneath; the as-of text sits on the right |
Text(body, size=9.0, muted=False) |
A paragraph, wrapped to the page width; \n starts a new line |
StatGrid(stats, columns=4) |
Headline figures, each a Stat(label, value, change=""), wrapping onto new rows |
Table(columns, rows, title="", align_right=[]) |
A header row and data rows; align_right lists column positions to right-align |
BarChart(categories, values, title="", height=140.0) |
Horizontal bars drawn directly in the PDF; negative values extend left of a centre axis |
Chart(image_path=None, title="", height=200.0) |
A rendered image file, scaled to the page width with its aspect ratio kept |
Every value shown on the page is a string you format yourself (Stat.value,
table cells, as_of), because only the caller knows whether a number is a
percentage, an amount or a ratio. Heights are in points (1/72 inch).
PageSetup(size="A4", orientation="portrait", margin=48.0) sets the sheet.
Sizes are A4, LETTER and A5; orientation is portrait or landscape;
the margin, in points, applies to all four sides.
Blocks check themselves when built and raise ReportingError: a table row
with the wrong number of cells, a bar chart with mismatched or no categories,
a stat grid with fewer than one column, or an unknown page size or
orientation.
Building a template¶
This example draws a correlation chart to a PNG, then lays out a one-page
factsheet around it. The chart needs the plot extra.
import matplotlib.pyplot as plt
import beacon.plot
from beacon.report import (
BarChart, Chart, Header, PageSetup, ReportTemplate,
Stat, StatGrid, Table, Text,
)
from beacon.risk import estimate_risk_model
from beacon.testing import dataset
risk = estimate_risk_model(dataset.returns(), intensity=0.1)
beacon.plot.use("light")
ax = risk.plot.correlation()
ax.figure.savefig("correlation.png", dpi=150)
plt.close("all")
weights = dataset.equal_weights()
template = ReportTemplate(
template_id="factsheet",
name="Canonical factsheet",
page=PageSetup(size="A4", orientation="portrait"),
blocks=[
Header("Canonical Index", subtitle="Monthly factsheet", as_of="31 Dec 2024"),
StatGrid([Stat("Level", "1,842.10", "+2.4% MTD"),
Stat("1Y return", "18.42%"),
Stat("Volatility", "15.84%"),
Stat("Constituents", str(len(weights)))]),
Text("Six synthetic companies, rebalanced quarterly.", muted=True),
BarChart(list(weights), [0.012, 0.009, 0.004, 0.021, -0.006, 0.003],
title="Contribution to return"),
Table(["Constituent", "Weight"],
[[name, f"{weight:.1%}"] for name, weight in weights.items()],
title="Holdings",
align_right=[1]),
Chart(image_path="correlation.png", title="Correlation", height=180.0),
])
BarChart suits a few labelled values such as top holdings, sector weights or
contributions, and needs no plotting library. Chart holds a path to an image
drawn elsewhere, usually by beacon.plot. If image_path is
None or the file does not exist, the renderer draws a dashed placeholder
labelled with the chart's title, so a layout can be reviewed before its
charts exist.
Rendering to PDF¶
beacon.report.pdf.render(template, output_path) draws the template and
returns the path it wrote. Parent folders are created as needed.
from beacon.report.pdf import render
path = render(template, "factsheet.pdf")
print(path, path.stat().st_size, "bytes")
What to expect from the renderer:
- One page. Blocks are laid out top to bottom. If they do not fit, it
raises
ReportingErrornaming the block that overflowed and how much room was left. It does not paginate. - Deterministic output. Two renders of the same template produce byte-identical files, so a hash confirms that two people got the same document.
- Fixed print colours. Colours come from the mode-independent paper design tokens, so a PDF looks the same whatever theme the application is using.
- Standard fonts. Helvetica, which every PDF reader has, so nothing is embedded.
- The template's
namebecomes the PDF title and itstemplate_idthe subject.
Importing beacon.report.pdf without reportlab raises
MissingDependencyError naming the pdf extra. beacon.report.blocks always
imports.
Saving and loading templates¶
to_dict() gives a JSON-ready form, with each block tagged by a kind, and
ReportTemplate.from_dict() reads it back:
import json
from beacon.report import ReportTemplate, block_from_dict
stored = json.dumps(template.to_dict(), indent=2)
restored = ReportTemplate.from_dict(json.loads(stored))
assert restored == template
note = block_from_dict({"kind": "text", "body": "Past performance is not a guide."})
print(note)
block_from_dict rebuilds a single block. The kinds are header, text,
stat_grid, table, bar_chart and chart. An unknown or missing kind
raises ReportingError rather than being skipped, since skipping would drop
content from the report without saying so.
The API server stores templates under /reports/templates and renders them
as a job through POST /reports/render, with the PDF downloaded from
GET /reports/renders/{render_id}. It also offers a built-in FACTSHEET-A4
template generated from an index's latest run. See the
Server guide.
Excel reports¶
ReportGenerator in beacon.portfolio.reporting writes two workbooks. Both
check for openpyxl when called, append .xlsx to a path that lacks it, and
raise ReportingError if writing fails.
import pandas as pd
from beacon.portfolio.base import Portfolio
from beacon.portfolio.reporting import ReportGenerator
portfolio = Portfolio("DEMO", initial_cash=100_000.0)
portfolio.execute_buy("AAA", 100, 120.0, cost=1.2, date=pd.Timestamp("2024-12-30"))
portfolio.update_prices({"AAA": 125.0}, date=pd.Timestamp("2024-12-31"))
reports = ReportGenerator()
reports.generate_holdings_report_excel(portfolio,
"holdings.xlsx",
valuation_date=pd.Timestamp("2024-12-31"))
reports.generate_performance_report_excel(portfolio.nav.to_frame("nav"),
"performance.xlsx",
report_title="Demo performance")
print(pd.ExcelFile("holdings.xlsx").sheet_names) # HoldingsSummary, TransactionHistory
generate_holdings_report_excel(portfolio, report_path, valuation_date)
writes a HoldingsSummary sheet from portfolio.get_holdings_summary(),
including a cash row, and a TransactionHistory sheet when the portfolio has
transactions. It reports the portfolio as it stands, so call
update_prices(...) first for current market values and weights.
valuation_date is used only in log messages; it is not written to the
workbook.
generate_performance_report_excel(performance_data, report_path, report_title="Performance Report")
writes a non-empty DataFrame, index included, to one sheet. The sheet is
named after report_title with spaces replaced by underscores and cut to 30
characters, or PerformanceData when the title is None. It raises
ValueError for an empty frame or anything that is not a DataFrame. A
backtest's NAV works well here: result.trading_nav.to_frame("nav") (see
Backtest).
Pass report_path as a string: both methods call str.endswith on it, so a
pathlib.Path fails.
The full API is in the Reports reference and the Portfolio reference.