Week 2: Data Download and Visualisation

Version 5.0 - August 2026

Author
Affiliation

Jon Danielsson

London School of Economics

2 Week 2

This week, we download data for ten securities from EOD Historical Data, align the observations on a common set of dates and save the resulting price and return files for use in the remaining seminars.

Securities trade on different days, so returns computed without aligning dates are quietly wrong. That is dealt with explicitly below.

We plot the price series to check for stale prices, missing periods and unusual adjustments for corporate actions.

NoteBefore you start

Open the same main course folder used in Week 1, select Anaconda’s Python and have your personal EODHD token available. This week creates Prices.csv and Returns.csv, and every later seminar depends on those two files.

TipBy the end of this session

You should have downloaded and aligned ten securities, checked the resulting dates and columns, and saved validated Prices.csv and Returns.csv files in the main course folder.

2.1 The plan for this week

  1. Import the packages we need
  2. Understand pandas Series and DataFrames for financial data
  3. Recall the single-stock workflow from Week 1
  4. Understand why date alignment matters, with a small worked example
  5. Scale to multiple stocks efficiently, fixing the pitfalls a naive loop invites
  6. Create structured price and return data frames with proper alignment
  7. Visualise and analyse the data
  8. Save data for use in later seminars

2.2 Packages needed for today

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from eodhd import APIClient

As in Week 1, the token goes straight into the code:

token = "demo"
api = APIClient(token)

The demo token will not get you through this seminar. It reaches only a handful of symbols — AAPL.US, TSLA.US, VTI.US, AMZN.US, BTC-USD.CC and EURUSD.FOREX — and this week downloads ten securities, two of them indices, none of which are on that list. Replace "demo" with your own key before running the download.

WarningProtect your token

Treat the token like a password. Do not paste it into an AI conversation or leave it in source that you submit or share. Hide its QMD code block with #| echo: false, replace the value with "YOUR_TOKEN" before sharing the source, and revoke a token that is exposed.

NoteIf EODHD reports an error

An authentication or access error usually means that the token or account entitlement is wrong. An unknown-symbol error usually means that the ticker or exchange code is wrong. For a rate-limit or server error, wait before retrying rather than repeatedly running the download loop.

2.3 Series and DataFrames for financial data

A pandas Series is a single labelled column, a one-dimensional array of values together with an index, most often a DatetimeIndex of trading dates. A DataFrame is a collection of Series that share an index, which is exactly the structure we want for price and return data with one column per security and one row per date. Because every column already carries the same date index, operations that combine columns — addition, division, differencing — line up by date automatically.

2.4 Finding stock symbols on EOD Historical Data

Symbols on EODHD take the form TICKER.EXCHANGE, for example AAPL.US. For US stocks the .US extension is required, and for indices the extension is INDX. See eodhd.com to search by name or browse by exchange.

2.4.1 Securities we analyse in this seminar

Eight stocks and two market indices, chosen to span technology, banking, energy, consumer and industrial businesses, so that the correlations in later seminars are not all of one kind.

tickers = ["GSPC", "IXIC", "AAPL", "MSFT", "JPM", "C", "XOM", "MCD", "GE", "NVDA"]

# The two indices sit on INDX, the eight stocks on US
exchanges = {
    "GSPC": "INDX",
    "IXIC": "INDX",
    "AAPL": "US",
    "MSFT": "US",
    "JPM": "US",
    "C": "US",
    "XOM": "US",
    "MCD": "US",
    "GE": "US",
    "NVDA": "US",
}

security_names = {
    "GSPC": "S&P 500",
    "IXIC": "NASDAQ Composite",
    "AAPL": "Apple",
    "MSFT": "Microsoft",
    "JPM": "JPMorgan",
    "C": "Citigroup",
    "XOM": "Exxon",
    "MCD": "McDonald's",
    "GE": "General Electric",
    "NVDA": "Nvidia",
}

Each security appears in both dictionaries under the same key, so its exchange and its name travel with its ticker rather than with its position in a list.

2.5 The single-stock workflow

Week 1 already walked through downloading one stock, computing its returns with np.log(prices["adjusted_close"]).diff() and the fact that pandas leaves the first return as NaN rather than shortening the Series. That workflow is the building block for what follows.

ImportantThe download function

download_one() is a function, a named block of code that performs a specific task. It takes a ticker and exchange as inputs and returns that security’s adjusted closing prices as a pandas Series. Defining the single-stock workflow once as a function lets the loop apply exactly the same steps to every security without copying the code ten times.

def download_one(ticker, exchange):
    """Download one security and return its adjusted close as a named Series."""
    raw = api.get_eod_historical_stock_market_data(
        symbol=f"{ticker}.{exchange}", period="d", order="a"
    )
    data = pd.DataFrame(raw)
    data["date"] = pd.to_datetime(data["date"])
    data = data.set_index("date")
    return data["adjusted_close"].rename(ticker)

2.6 Why date alignment matters

Before downloading all ten securities, look at why alignment is necessary in the first place, with a small example that needs no network access. Two securities rarely trade on exactly the same set of days — one might be listed later, or closed for a local holiday the other observes:

a_dates = pd.to_datetime(["2024-01-02", "2024-01-03", "2024-01-04"])
b_dates = pd.to_datetime(["2024-01-02", "2024-01-04"])  # missing 3 January

a = pd.Series([100.0, 101.0, 102.0], index=a_dates, name="a")
b = pd.Series([50.0, 51.0], index=b_dates, name="b")

The naive approach drops the index and works with the raw values directly:

wrong = pd.DataFrame({"a": a.values, "b": b.values})

This would raise ValueError: All arrays must be of the same length because the two arrays contain different numbers of observations. With real data the lengths can coincide by chance while the dates still do not, which is worse, because the mismatch is then silent rather than an error. The safe approach keeps the Series — index and all — and lets pandas align them.

aligned = pd.DataFrame({"a": a, "b": b})
aligned
a b
2024-01-02 100.00000000 50.00000000
2024-01-03 101.00000000 NaN
2024-01-04 102.00000000 51.00000000

pandas has taken the union of the two indices and inserted NaN wherever a security has no observation for a given date. This happens automatically because both inputs carry their own date index. The alternative is matching the dates by hand, which is a reliable source of errors.

2.7 Downloading and aligning all ten securities

A Python dictionary stores values under named keys. price_series = {} creates an empty dictionary. Each pass through the loop uses a ticker as the key and the downloaded pandas Series as its value, so, for example, price_series["AAPL"] contains Apple’s adjusted closing prices.

The loop takes each ticker in turn and looks up its exchange with exchanges[ticker]. A misspelled ticker then stops the program with a KeyError rather than quietly downloading the wrong series.

# One call per ticker, not two: calling twice inside the loop and
# discarding the first result is an easy and expensive mistake.
price_series = {}
for ticker in tickers:
    exchange = exchanges[ticker]
    print(f"Downloading {security_names[ticker]} ({ticker})")
    price_series[ticker] = download_one(ticker, exchange)

pd.concat() combines the Series in price_series into one DataFrame, matching the rows by date as in the example above.

Prices = pd.concat(price_series, axis=1)
Prices.shape

With the prices in one DataFrame, we calculate log returns for every security:

Returns = np.log(Prices).diff()

np.log(Prices) takes the logarithm of every price. .diff() then subtracts the preceding value in the same column, so each security’s returns are calculated from its own prices.

Keep observations from 1 January 2000 onward:

Prices = Prices[Prices.index >= "2000-01-01"]
Returns = Returns[Returns.index >= "2000-01-01"]
print("Prices:", Prices.shape)
print("Returns:", Returns.shape)
((6470, 10), (6470, 10))
print("Missing values in Prices:")
print(Prices.isna().sum())
print("\nMissing values in Returns:")
print(Returns.isna().sum())
Missing values in Prices:
GSPC    0
IXIC    0
AAPL    0
MSFT    0
JPM     0
C       0
XOM     0
MCD     0
GE      0
NVDA    0
dtype: int64

Missing values in Returns:
GSPC    0
IXIC    0
AAPL    0
MSFT    0
JPM     0
C       0
XOM     0
MCD     0
GE      0
NVDA    0
dtype: int64
NoteCheck your result

Both frames should have the ten columns listed above, the same increasing date index and several thousand rows beginning after 1 January 2000. Missing values are expected where a security did not trade. A missing security column is not.

2.8 Visualising financial data

2.8.1 One asset

Returns.index holds the trading dates, so it can be used directly on the horizontal axis.

fig, ax = plt.subplots()
ax.plot(Returns.index, Returns["JPM"])
plt.show()

We can improve this visualisation:

fig, ax = plt.subplots()
ax.plot(Returns.index, Returns["JPM"], color="red")
ax.set_title("Log returns for JPMorgan")
ax.set_ylabel("Returns")
ax.set_xlabel("Date")
plt.show()

2.8.2 Multiple stocks on one plot

fig, ax = plt.subplots()
ax.plot(Prices.index, Prices["JPM"], color="darkblue", label="JPMorgan")
ax.plot(Prices.index, Prices["C"], color="darkred", label="Citigroup")
ax.set_title("Bank stock prices: JPM vs Citigroup")
ax.set_ylabel("Price (USD)")
ax.set_xlabel("Date")
ax.legend(frameon=False)
plt.show()

matplotlib recalculates the axis limits to fit every series added to the same axes by the time the figure is drawn, so a later series is never clipped by limits that an earlier one set.

2.8.3 Normalised price comparison

Normalising prices shows relative performance regardless of absolute price levels. A stock that goes from $10 to $20 has the same relative performance as one that goes from $100 to $200, even though the absolute price changes differ.

NormalisedPrices = Prices / Prices.iloc[0]

fig, ax = plt.subplots()
for ticker in tickers:
    ax.plot(NormalisedPrices.index, NormalisedPrices[ticker], label=ticker)
ax.set_title("Normalised stock and index prices (starting at 1)")
ax.set_ylabel("Normalised price")
ax.set_xlabel("Date")
ax.legend(frameon=False, ncol=2, fontsize="small")
plt.show()

2.8.4 Log scale plot

Log-scale plotting makes percentage changes appear as equal distances on the chart, which is how financial analysts think about returns. A 10% increase looks the same whether it is from $10 to $11 or from $100 to $110.

fig, ax = plt.subplots()
for ticker in tickers:
    ax.plot(NormalisedPrices.index, NormalisedPrices[ticker], label=ticker)
ax.set_title("Stock prices (log scale)")
ax.set_ylabel("Normalised price")
ax.set_xlabel("Date")
ax.set_yscale("log")
ax.legend(frameon=False, ncol=4, fontsize="small")
plt.show()

2.8.5 Subplots for returns

fig, axes = plt.subplots(4, 3, figsize=(8, 10))
for i in range(len(tickers)):
    ax = axes.flat[i]
    ticker = tickers[i]
    colour = "darkblue" if ticker in ("GSPC", "IXIC") else "darkgreen"
    ax.plot(Returns.index, Returns[ticker], color=colour)
    ax.set_title(f"Returns for {security_names[ticker]}")
    ax.set_ylabel("Returns")
    ax.set_xlabel("Date")
    ax.xaxis.set_major_locator(mdates.YearLocator(10))
    ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y"))
for ax in axes.flat[len(tickers):]:
    ax.set_visible(False)
fig.tight_layout()
plt.show()

The individual panels are narrow, so their x-axes show one label every ten years. More frequent year labels would overlap.

2.9 Exporting plots

For reports and presentations, fig.savefig() writes the current figure to disk in whatever format the file extension implies:

fig, ax = plt.subplots()
ax.plot(Returns.index, Returns["AAPL"], color="blue")
ax.set_title("Apple returns")
ax.set_ylabel("Returns")
ax.set_xlabel("Date")
fig.savefig("returns_plot.pdf")
fig.savefig("returns_plot.png", dpi=300)
fig.savefig("returns_plot.svg")

PDF and SVG preserve lines and text as vector graphics, so they remain sharp when resized. PNG is a raster image and is convenient for slides and web pages; dpi=300 produces a high-resolution version.

2.10 Saving data frames

These two files are the permanent output of the week. Save them in the main course folder so that later seminars can load them without repeating the download and alignment. CSV is plain text, readable in Excel or any editor, and needs nothing beyond pandas itself.

Prices.to_csv("Prices.csv")
Returns.to_csv("Returns.csv")

Reload the saved files immediately. This checks the files on disk, rather than only the objects still held in the current Python session:

An assert statement checks that a condition is true. If it is true, Python continues without printing anything. If it is false, Python stops with an AssertionError, showing that the saved data do not have the expected structure. The four assertions below check the price columns, the return columns, whether both files contain exactly the same dates, and whether those dates are in increasing order.

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

expected_columns = ["GSPC", "IXIC", "AAPL", "MSFT", "JPM", "C", "XOM", "MCD", "GE", "NVDA"]
assert list(saved_prices.columns) == expected_columns
assert list(saved_returns.columns) == expected_columns
assert saved_prices.index.equals(saved_returns.index)
assert saved_prices.index.is_monotonic_increasing

print("Prices.csv:", saved_prices.shape, saved_prices.index.min(), saved_prices.index.max())
print("Returns.csv:", saved_returns.shape, saved_returns.index.min(), saved_returns.index.max())
Prices.csv: (6470, 10) 2000-01-03 00:00:00 2025-09-23 00:00:00
Returns.csv: (6470, 10) 2000-01-03 00:00:00 2025-09-23 00:00:00
TipCourse data checkpoint

Do not continue until all four assertions run without error and the printed output shows ten columns and several thousand dated rows. Keep Prices.csv and Returns.csv in this folder: Weeks 3–10 read these exact files.

To load the data in a later seminar:

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

2.11 Recap

2.11.1 In this seminar, we have covered:

  • Finding stock symbols on EOD Historical Data
  • Wrapping the Week 1 single-stock workflow in a reusable function
  • Why date alignment matters, with a small worked example
  • Downloading data for multiple stocks including indices, one call per ticker
  • Creating price and return data frames with pd.concat alignment
  • Handling missing data with NaN values
  • Saving data as CSV files
  • Creating various types of plots:
    • Single time series plots
    • Multiple series on one set of axes
    • Subplot grids
    • Normalised comparisons
    • Log-scale visualisations
  • Customising plots with colours, labels and legends

2.11.2 Some new functions used:

  • download_one() — our function wrapping a single EODHD download
  • pd.concat() — combine Series or DataFrames, aligning on the index
  • .isna().sum() — count missing values per column
  • .set_index() — use a column as the DataFrame’s index
  • fig, axes = plt.subplots(nrows, ncols) — create a grid of subplots
  • .to_csv() / pd.read_csv(index_col="date", parse_dates=True) — save and load a data frame, restoring the date index on the way back in
  • fig.savefig() — write a figure to disk

2.12 Exercises

2.12.1 Apply the method

Use the validation output and the plots to answer three questions. Which security has the shortest history, which has the greatest return standard deviation, and why does aligning by date matter before comparing two assets?

2.12.2 Compare results

Add one further stock to tickers, exchanges and security_names, rerun the download function, and repeat the course data checkpoint. Check its missing observations and compare its volatility with the existing ten assets.

2.12.3 Extend the analysis

  1. Modify download_one() so it can return either simple or log returns.
  2. Compare the COVID period with a recent stress period using cumulative returns and volatility by sector.
  3. Build a subplot grid of the ten return series and a second plot showing how many securities have data on each date.