Week 4: Reporting with Quarto and Python

Version 5.0 - August 2026

Author
Affiliation

Jon Danielsson

London School of Economics

4 Week 4

Most empirical analysis ends in a report with numbers, tables and figures that have to be calculated, checked and explained to investors, regulators, clients or colleagues. A simple way to produce one is to keep the two apart, running Python and then copying its results into another document, perhaps Word or PowerPoint. That is slow, inefficient and error-prone, and every update to the analysis repeats the whole exercise.

There is a better way, which is to combine the numerical results and the written analysis in a single document. Several formats do that, and the common one is Markdown. It is plain text with simple formatting, and it holds the prose, the tables and the figures of a report in one readable file.

Markdown has become especially common recently because it is the language of AI systems, and of agentic AI in particular. An agent writes code, generates figures and analyses numbers within a single document, and that document is Markdown.

Once a Markdown document holds the numerical analysis, exporting it to a final format is straightforward, whether that is a web page, an app, a PDF or a PowerPoint presentation.

Markdown on its own, however, calculates nothing, so an extended form of it is needed. Several alternatives exist, and one of the best is Quarto used with Positron. The Python is embedded in the Markdown, and rendering the file runs the code and places its numbers, tables and figures in the text. If the data or the code change, rendering again updates every result.

NoteBefore you start

Open the main course folder and check that Prices.csv and Returns.csv are present. Positron must be using Anaconda’s Python. Return to the setup check if necessary.

TipBy the end of this session

You should be able to combine prose and Python in a QMD, insert calculated values into sentences, add captioned tables and figures, refer to them from the text, and render the result as a report.

4.1 The plan for this week

  1. Create a QMD and set its YAML header
  2. Add a chunk that loads the data
  3. Decide what the reader sees of the code and the results
  4. Display a table of results
  5. Put calculated values inside sentences
  6. Add a captioned table and figure, refer to them, and interpret them

4.2 What Quarto does

A Quarto file takes the extension qmd, as in report.qmd, and contains four things:

  1. A YAML header holding the configuration of the document.
  2. Markdown for headings, paragraphs, lists and links.
  3. Python chunks for calculations, tables and figures.
  4. Inline Python expressions for calculated values inside sentences.

We generate the report by rendering, which executes the Python and combines its output with the Markdown. Rendering starts a new Python session and runs each block of code, called a chunk, in order from the top of the document.

4.3 Create the report

In Positron, use File > New File, choose Quarto Document, and save the file as week4_report.qmd in the main course folder.

Every QMD begins with a YAML header, the block between the two lines of three dashes at the top of the file. It is the configuration of the document rather than anything Python runs, and it holds the title, the author, the output format and various options belonging to that format. Quarto’s format reference lists what may appear there for HTML output.

Positron creates the new file with a header of its own. The one for this report is:

---
title: "Stock Price and Return Analysis"
author: "Your name"
date: today
format:
  html:
---

format states what the finished document is to be. Here it is a web page, that is HTML, but it could equally be Word, PowerPoint or something else. Indentation carries meaning in YAML, so keep the spacing as shown.

Add this short introduction below the YAML:

This report summarises the price and daily log returns of one stock. Its
results are calculated directly from the course data.

Click Preview, or press Cmd-Shift-K on macOS or Ctrl-Shift-K on Windows. Positron renders the document and opens it in the Viewer pane. At this stage it contains only the title and introduction. Rendering is what Quarto does to the file, and Preview is the button that asks for it while the report is being written.

4.4 Add the data and Python setup

An executable Python chunk starts with three backticks followed by the language in curly brackets, ```{python}, and ends with three backticks.

```{python}
a = 2
print("The number is", a**2)
```

We now load the standard libraries and the data, and pick JPM for the analysis.

```{python}
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import Markdown

Prices = pd.read_csv("Prices.csv", index_col="date", parse_dates=True)
Returns = pd.read_csv("Returns.csv", index_col="date", parse_dates=True)

stock = "JPM"
stock_prices = Prices[stock].dropna()
stock_returns = Returns[stock].dropna()
```

The chunk imports pandas and matplotlib, together with Markdown, which is used below to place tables in the report. It then loads the two course files and selects one column from each. The call to .dropna() removes any missing observations from the selected Series. The report will use stock everywhere, so the selected security can later be changed in one place.

Preview again. This chunk assigns objects rather than displaying a result, so the important checkpoint is that the report renders without an error.

4.5 Control what the reader sees

The seminar shows Python because part of its purpose is to teach the code. A finished report may have a different purpose. Keep the code visible while building and checking the report, then decide whether its reader needs to see it.

A chunk can put two things into the document, its code and the result the code produces, and they are controlled separately. Both are set by cell options, lines beginning with #| at the top of the chunk:

Option Code Result
none shown shown
echo: false hidden shown
output: false shown hidden
include: false hidden hidden

4.6 Suppressing the code

A report usually does not need the code that produced its results, and echo: false removes it. Applied to the arithmetic chunk above, the printed line stays and the two lines that produced it go:

```{python}
#| echo: false

a = 2
print("The number is", a**2)
```

The chunk still runs and still prints its line. Only the code disappears from the rendered document. The setup chunk is the other case, since it produces nothing to keep, and the same option there removes it from the report altogether while the objects it creates remain available to every chunk below.

4.7 Displaying results

If we want a table of output there are several ways. We can simply print part of the data frame, but there is also a command called Markdown that produces tables in a format better suited to a report.

```{python}
#| echo: false

Markdown(stock_prices.head().to_frame().to_markdown())
```

After rendering, the first rows appear in the report:

date JPM
2000-01-03 00:00:00 23.1248
2000-01-04 00:00:00 22.6175
2000-01-05 00:00:00 22.4778
2000-01-06 00:00:00 22.797
2000-01-07 00:00:00 23.2158

The dates read badly, since each one carries a time of day that the data does not have, and the prices are shown to more decimals than a reader needs. The index is rewritten as dates in a readable form, and floatfmt gives every number the same two decimals, which .round() would not do since it drops a trailing zero:

```{python}
#| echo: false

first_rows = stock_prices.head().to_frame()
first_rows.index = first_rows.index.strftime("%d %B %Y")
Markdown(first_rows.to_markdown(floatfmt=".2f"))
```

After rendering:

date JPM
03 January 2000 23.12
04 January 2000 22.62
05 January 2000 22.48
06 January 2000 22.80
07 January 2000 23.22

The last expression in a chunk is displayed, which is how a value or a table reaches the report without a print() call. .head() returns the first five observations and .to_frame() turns them into a one-column table. .to_markdown() writes that table as Markdown, and Markdown() hands it to Quarto as text to be laid out rather than as text to be printed. Five rows are enough to confirm that the dates and the prices are the ones the report is about.

4.8 Insert calculations into the text

There are two main ways of reporting a result. We can print it inside a Python chunk, as above, or we can place it inline, as in the price of the stock is $22.50.

Inline Python is an expression placed inside a sentence. It is written between single backticks, with {python} at the front to mark what follows as code rather than as text to be typeset. Rendering replaces the whole span by the value the expression returns, so {python} len(stock_returns) becomes the number of observations and the sentence around it reads normally.

The sample contains `{python} len(stock_returns)` daily log returns
for `{python} stock`. Its mean daily log return
is `{python} f"{stock_returns.mean() * 100:.3f}"`%. The most negative
daily log return is `{python} f"{stock_returns.min() * 100:.2f}"`%,
on `{python} stock_returns.idxmin().strftime("%d %B %Y")`.

After rendering, the Python expressions are replaced by their values:

The sample contains 6470 daily log returns for JPM. Its mean daily log return is 0.039%. The most negative daily log return is -23.23%, on 20 January 2009.

The f-strings control presentation. The mean has three decimal places and the minimum has two. Multiplying by 100 expresses the decimal log returns in percentage units, while .strftime() gives the date a readable format. The rounding affects only the displayed text, not the underlying calculations.

4.9 Add a summary table

Summary statistics do not have to be assembled statistic by statistic. The pandas method .describe() returns the standard set in one call, and the chunk is then two lines. Add it below the paragraph just written:

```{python}
#| label: tbl-summary
#| tbl-cap: "Summary statistics for daily log returns"
#| echo: false

summary = (stock_returns * 100).describe().drop("count").to_frame("Daily log return (%)")
Markdown(summary.to_markdown(floatfmt=".3f"))
```

After rendering, the table appears as follows:

Table 1: Summary statistics for daily log returns
Daily log return (%)
mean 0.039
std 2.326
min -23.228
25% -0.860
50% 0.039
75% 0.966
max 22.392

Multiplying by 100 puts the statistics into percentage units, .drop("count") removes the number of observations, which is not a percentage and does not belong in the same column, and .to_frame() supplies the column heading.

The chunk carries two further cell options. label names the chunk, and a label beginning with tbl- tells Quarto that the output is a table, which is what makes it referenceable. tbl-cap gives the caption printed with it, numbered by Quarto rather than by hand.

In the QMD source, refer to the table by its label. Quarto supplies the word “Table” and the number, so neither is written out:

@tbl-summary reports the return statistics for `{python} stock`.

After rendering, the reference becomes the table’s name and number:

Table 1 reports the return statistics for JPM.

4.9.1 Customised table

This section is optional, and the report is complete without it. .describe() is fixed in what it calculates and in how it labels the rows. When a report needs a particular set of statistics, or names a reader will recognise, the table can be assembled directly instead:

```{python}
#| label: tbl-summary
#| tbl-cap: "Summary statistics for daily log returns"
#| echo: false

summary = (
    stock_returns
    .agg(["mean", "std", "min", "max"])
    .mul(100)
    .rename(index={
        "mean": "Mean",
        "std": "Standard deviation",
        "min": "Minimum",
        "max": "Maximum",
    })
    .to_frame("Daily log return (%)")
)
Markdown(summary.to_markdown(floatfmt=".3f"))
```

.agg() calculates the statistics that are asked for, .mul(100) converts them to percentages, .rename() replaces the labels with the wording the report uses and .to_frame() names the column. The result is the same table as before with four rows instead of seven and the names spelled out, and the method extends to any statistic that can be calculated, so a report is never limited to what one command happens to supply.

Use one version or the other in week4_report.qmd. Two chunks cannot share the label tbl-summary.

4.10 Add a figure

Add this complete chunk after the table discussion:

```{python}
#| label: fig-price
#| fig-cap: "Adjusted price over the sample"
#| echo: false

fig, ax = plt.subplots()
ax.plot(stock_prices.index, stock_prices)
ax.set_yscale("log")
ax.set_title(f"{stock} adjusted price")
ax.set_xlabel("Date")
ax.set_ylabel("Adjusted price")
fig.tight_layout()
plt.show()
```

After rendering, the figure appears as follows:

Figure 1: Adjusted price over the sample

The date index supplies the x-axis. A log scale is useful over a long price history because equal proportional changes occupy equal vertical distances. The fig- label and fig-cap option make the figure captioned and referenceable.

The chunk gives the figure a title and a caption, which are not the same thing. ax.set_title() draws its text inside the image, where it becomes part of the picture and stays with it if the figure is ever used elsewhere. fig-cap is placed below the figure by Quarto, which numbers it and makes it what @fig-price points to. A written report usually needs only the caption. The title is kept here because it names the security and follows stock, which a caption written as a cell option cannot do.

In the QMD source, add:

@fig-price shows the adjusted price history for `{python} stock`.

After rendering, this becomes:

Figure 1 shows the adjusted price history for JPM.

Quarto inserts the figure into the rendered report automatically. A separate image file is unnecessary. When one is required for other work, return to exporting plots in Week 2.

4.11 Interpret the results

A reader needs to be told what the table and the figure show. Add a short paragraph that says so, with the units. This version remains valid when stock changes:

@tbl-summary shows that the daily log return standard deviation
for `{python} stock` is `{python} f"{stock_returns.std() * 100:.3f}"`%,
compared with a mean of `{python} f"{stock_returns.mean() * 100:.3f}"`%.
The variation from one day to the next is many times the average return.
@fig-price shows the stock's longer-run price path. It describes the price
level rather than the day-to-day variation reported in the table.

After rendering, the calculations and references become part of the prose:

Table 1 shows that the daily log return standard deviation for JPM is 2.326%, compared with a mean of 0.039%. The variation from one day to the next is many times the average return. Figure 1 shows the stock’s longer-run price path. It describes the price level rather than the day-to-day variation reported in the table.

4.12 The complete report

The finished week4_report.qmd is below, with every chunk hiding its code as a report normally would. Use it to check the order of the pieces and that nothing has been missed.

---
title: "Stock Price and Return Analysis"
author: "Your name"
date: today
format:
  html:
---

This report summarises the price and daily log returns of one stock. Its
results are calculated directly from the course data.

```{python}
#| echo: false

import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import Markdown

Prices = pd.read_csv("Prices.csv", index_col="date", parse_dates=True)
Returns = pd.read_csv("Returns.csv", index_col="date", parse_dates=True)

stock = "JPM"
stock_prices = Prices[stock].dropna()
stock_returns = Returns[stock].dropna()
```

```{python}
#| echo: false

first_rows = stock_prices.head().to_frame()
first_rows.index = first_rows.index.strftime("%d %B %Y")
Markdown(first_rows.to_markdown(floatfmt=".2f"))
```

The sample contains `{python} len(stock_returns)` daily log returns
for `{python} stock`. Its mean daily log return
is `{python} f"{stock_returns.mean() * 100:.3f}"`%. The most negative
daily log return is `{python} f"{stock_returns.min() * 100:.2f}"`%,
on `{python} stock_returns.idxmin().strftime("%d %B %Y")`.

```{python}
#| label: tbl-summary
#| tbl-cap: "Summary statistics for daily log returns"
#| echo: false

summary = (stock_returns * 100).describe().drop("count").to_frame("Daily log return (%)")
Markdown(summary.to_markdown(floatfmt=".3f"))
```

@tbl-summary reports the return statistics for `{python} stock`.

```{python}
#| label: fig-price
#| fig-cap: "Adjusted price over the sample"
#| echo: false

fig, ax = plt.subplots()
ax.plot(stock_prices.index, stock_prices)
ax.set_yscale("log")
ax.set_title(f"{stock} adjusted price")
ax.set_xlabel("Date")
ax.set_ylabel("Adjusted price")
fig.tight_layout()
plt.show()
```

@fig-price shows the adjusted price history for `{python} stock`.

@tbl-summary shows that the daily log return standard deviation
for `{python} stock` is `{python} f"{stock_returns.std() * 100:.3f}"`%,
compared with a mean of `{python} f"{stock_returns.mean() * 100:.3f}"`%.
The variation from one day to the next is many times the average return.
@fig-price shows the stock's longer-run price path. It describes the price
level rather than the day-to-day variation reported in the table.

4.13 Other output formats

The report renders to a web page because that is what its header asks for. The same source produces other formats by changing one line, and Quarto will produce several at once if the header lists them:

format:
  html:
  docx:
  pptx:

Preview produces one format, the one chosen beside the button. To produce every format the header declares, run Quarto: Render Document from the command palette, Cmd-Shift-P on macOS or Ctrl-Shift-P on Windows.

The choices worth knowing are these.

html is a web page, the default here. Interactivity survives only in the web formats, html and revealjs. That covers code folded behind a toggle with code-fold: true, and figures a reader can zoom and hover over, from a plotting library such as plotly. A document whose numbers recalculate as a reader changes an input needs Quarto’s Shiny or Observable support and a server to run on, which is beyond this course.

docx is a Word document, which suits a reader who wants to edit or comment on the text, and pptx is a PowerPoint presentation.

revealjs is a presentation that runs in a browser rather than in PowerPoint.

pdf is the format for anything to be printed or submitted as a fixed document. It needs a LaTeX installation, which the setup guide covers in Making PDFs.

Most of the content carries across unchanged, but the two presentation formats do not simply reflow. Each level-two heading starts a new slide, so a document written as continuous prose becomes a small number of overcrowded slides. Presentations have to be reorganised into shorter sections, with more headings and less text under each.

4.14 Recap

4.14.1 In this seminar, we have covered:

  • Writing a report as a QMD document:
    • YAML settings for the title, author, date and output format
    • Markdown prose combined with executable Python chunks
  • Calculated values inside the text:
    • Inline Python expressions
    • Formatting numbers and dates for a reader with f-strings
  • Tables and figures a reader can cite:
    • Chunk labels and captions using tbl- and fig-
    • Cross-references that supply their own name and number
    • Interpretation written so that it survives a change of security
  • Controlling the rendered document:
    • Choosing what appears with echo, output and include
    • Hiding code with echo: false
    • Folding code behind a toggle in HTML with code-fold: true
    • Rendering one source to HTML, Word, PDF or slides

4.14.2 Some new functions used:

  • .describe() — return the standard summary statistics in one call
  • .drop() — remove a row by its label
  • .agg() — calculate a chosen set of statistics
  • .mul() — multiply a Series or data frame by a constant
  • .rename(index=...) — relabel the rows of a Series or data frame
  • .to_frame() — turn a Series into a one-column table
  • .to_markdown() — write a table as Markdown, with floatfmt setting the decimals
  • Markdown() — pass Markdown from a chunk to Quarto for layout
  • .idxmin() — return the index label of the smallest value
  • .strftime() — format a date for display

4.15 Exercises

4.15.1 Apply the method

Change stock to another security in the course data and render the report again. Check that the inline numbers, the table, the figure title and the written references all update. Revise the interpretation where the evidence calls for it.

4.15.2 Compare results

Extend the report to compare two stocks. Include a captioned table of their return statistics, a captioned figure of their normalised adjusted prices as in Week 2, and an inline statement identifying which of the two has the larger return standard deviation. Refer to the table and the figure from the prose.

4.15.3 Extend the analysis

  1. Write a short report on the 2008 financial crisis. Select the relevant dates as in Week 3, present one table and one figure, report the most negative daily log return and its date inline, and finish with a concise interpretation of the evidence.
  2. Report on all ten securities at once, with one captioned table of their return standard deviations, one captioned figure of their normalised prices, and a paragraph naming the most and the least volatile. Take the names from the inline calculation rather than typing them, so that the sentence survives a change of data.
  3. Take the summary statistics of Week 3, the normality and autocorrelation tests among them, and present them as a report a reader could follow without seeing any code.

4.15.4 Try another output format

Render the completed report in one other format by changing format in the YAML. If you choose pptx or revealjs, reorganise the material so that each slide carries one point, and compare the result with the web page to see what the change of format costs and what it gains.