Project: Nigerian Stock Market Analysis
The Project
The Nigerian Exchange (NGX), formerly the Nigerian Stock Exchange (NSE), lists over 150 companies, including heavyweights such as Dangote Cement, MTN Nigeria, Airtel Africa, BUA Cement, Zenith Bank and GTCO. Investment firms such as Stanbic IBTC Asset Management, ARM and Meristem employ analysts to study exactly the questions in this project.
Your brief: you are a junior analyst at a Lagos asset manager. Analyse two years of daily data for six NGX-listed stocks and answer:
- Which stocks delivered the best return, and at what risk?
- How do banking and telecom stocks move relative to each other?
- When did unusual trading volume occur, and did prices react?
- What would a simple moving-average strategy have done?
Open in Google Colab: NGX Stock Analysis Notebook
Open a new notebook and work through each step below. The first cell generates a simulated two-year price and volume dataset for six NGX tickers, so the notebook runs anywhere. Once your analysis works, swap in real data (see Step 8).
Step 1: Generate the Dataset
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style="whitegrid")
rng = np.random.default_rng(2024)
dates = pd.bdate_range("2023-01-02", "2024-12-31") # trading days (Mon-Fri)
tickers = { # (start price, annual drift, annual volatility, sector)
"DANGCEM": (260, 0.35, 0.30, "Industrial"),
"BUACEMENT": (95, 0.20, 0.28, "Industrial"),
"MTNN": (230, 0.05, 0.32, "Telecom"),
"AIRTELAFRI": (1500, 0.30, 0.35, "Telecom"),
"ZENITHBANK": (24, 0.45, 0.40, "Banking"),
"GTCO": (25, 0.55, 0.42, "Banking"),
}
market = rng.normal(0, 0.008, len(dates)) # shared market shock
sector_shock = {s: rng.normal(0, 0.006, len(dates)) for s in ["Industrial", "Telecom", "Banking"]}
rows = []
for t, (p0, mu, sigma, sector) in tickers.items():
daily = mu / 252 + market + sector_shock[sector] + rng.normal(0, sigma / np.sqrt(252), len(dates))
close = p0 * np.exp(np.cumsum(daily))
volume = rng.lognormal(13, 0.5, len(dates)) * (1 + 4 * (np.abs(daily) > 2.5 * sigma / np.sqrt(252)))
rows.append(pd.DataFrame({"date": dates, "ticker": t, "sector": sector,
"close": close.round(2), "volume": volume.round(-2)}))
prices = pd.concat(rows, ignore_index=True)
prices.head()
This is long format: one row per ticker per day. It is ideal for groupby. For time series maths, you will often pivot it to wide format with one column per ticker.
close = prices.pivot(index="date", columns="ticker", values="close")
volume = prices.pivot(index="date", columns="ticker", values="volume")
close.tail()
Step 2: Price Trends
Raw prices are hard to compare because they range from about N25 to over N1,500. Normalise each stock to start at 100:
normalised = close / close.iloc[0] * 100
normalised.plot(figsize=(12, 5), title="Growth of 100 invested (Jan 2023 = 100)")
plt.ylabel("Index")
plt.show()
Step 3: Returns and Risk
Daily return is the percentage change from one day to the next:
returns = close.pct_change().dropna()
summary = pd.DataFrame({
"total_return_%": (close.iloc[-1] / close.iloc[0] - 1) * 100,
"annual_volatility_%": returns.std() * np.sqrt(252) * 100,
})
summary["return_per_unit_risk"] = summary["total_return_%"] / summary["annual_volatility_%"]
summary.sort_values("return_per_unit_risk", ascending=False).round(2)
Volatility (the annualised standard deviation of daily returns) is the standard measure of risk. Multiplying by the square root of 252 converts daily volatility to annual, because there are about 252 trading days in a year.
Maximum drawdown
The worst peak-to-trough fall is what investors actually feel:
running_peak = close.cummax()
drawdown = (close - running_peak) / running_peak * 100
drawdown.min().sort_values().round(1) # worst fall for each stock, in %
Step 4: How Do Stocks Move Together?
corr = returns.corr()
sns.heatmap(corr, annot=True, fmt=".2f", cmap="RdBu_r", vmin=-1, vmax=1)
plt.title("Correlation of daily returns")
plt.show()
Stocks in the same sector (ZENITHBANK and GTCO, MTNN and AIRTELAFRI) should correlate more strongly with each other. For a portfolio manager, low correlation means diversification: when one stock falls, another may not.
Sector-level view:
sector_returns = prices.assign(ret=prices.groupby("ticker")["close"].pct_change()) \
.groupby(["date", "sector"])["ret"].mean().unstack()
(1 + sector_returns.fillna(0)).cumprod().plot(figsize=(12, 4), title="Cumulative return by sector")
Step 5: Volume Analysis
Unusual volume often signals news: earnings, dividends, regulatory announcements or large institutional trades.
vol_z = (volume - volume.rolling(20).mean()) / volume.rolling(20).std()
spikes = vol_z[vol_z > 3].stack().reset_index()
spikes.columns = ["date", "ticker", "volume_z"]
spikes.sort_values("volume_z", ascending=False).head(10)
Did prices move on spike days? Compare absolute returns:
abs_ret = returns.abs().stack()
is_spike = (vol_z.reindex(returns.index) > 3).stack()
print("Avg absolute return on spike days: ", round(abs_ret[is_spike].mean() * 100, 2), "%")
print("Avg absolute return on normal days: ", round(abs_ret[~is_spike].mean() * 100, 2), "%")
Step 6: Moving Averages
A moving average smooths out daily noise. A classic technical signal is the crossover: when the short-term average crosses above the long-term average, momentum is turning up.
stock = close["GTCO"].to_frame("close")
stock["ma20"] = stock["close"].rolling(20).mean()
stock["ma50"] = stock["close"].rolling(50).mean()
stock.plot(figsize=(12, 5), title="GTCO with 20- and 50-day moving averages")
plt.show()
Step 7: Backtest a Simple Strategy
stock["signal"] = (stock["ma20"] > stock["ma50"]).astype(int).shift(1) # act the NEXT day
stock["ret"] = stock["close"].pct_change()
stock["strategy_ret"] = stock["signal"] * stock["ret"]
growth = (1 + stock[["ret", "strategy_ret"]].fillna(0)).cumprod()
growth.columns = ["Buy and hold", "MA crossover"]
growth.plot(figsize=(12, 4), title="Strategy vs buy and hold (GTCO)")
Notice .shift(1): you can only act on a signal the day after you see it. Forgetting this is look-ahead bias, a form of data leakage that makes strategies look far better than they really are.
Honest caveats for your write-up: this ignores transaction costs, taxes and liquidity (many NGX stocks trade thinly), and past performance does not predict future returns. This is an analysis exercise, not investment advice.
Step 8: Use Real NGX Data
Once your notebook works, replace Step 1 with real data:
- NGX Group (ngxgroup.com) publishes daily price lists and market data for listed equities
- Brokerage and financial data sites also publish historical NGX prices that can be downloaded as CSV
Load the file and reshape it to the same long format (date, ticker, close, volume), and every later step will run unchanged. Real data will need cleaning: missing days, stock splits and bonus issues, and adjustments for dividends.
Deliverable
A notebook with markdown commentary under every chart, plus a five-bullet executive summary at the top answering the four questions in the brief. Push it to GitHub. It is a strong, locally relevant portfolio project.
Try it yourself
Key Takeaways
- Pivot long-format price data to wide format with one column per ticker for time series calculations such as returns and correlations.
- Normalise prices to a common base to compare growth, and measure risk with annualised volatility and maximum drawdown.
- Correlation of daily returns reveals sector co-movement and diversification opportunities.
- Volume z-scores flag unusual trading days worth linking to news and price moves.
- Backtests must shift signals to avoid look-ahead bias and should state their caveats: costs, liquidity, and no guarantee of future returns.
Quick Quiz
1.Why normalise each stock's price to 100 at the start date before plotting?
2.Why does the backtest use .shift(1) on the trading signal?
3.How is annualised volatility calculated from daily returns?
Ready to go further?
CareerEx gives you structured 12-week training, live classes every Saturday and Sunday, real tutor feedback, and a certificate. Join the next cohort.
Join CareerEx