We download the Fama-French 6 Portfolios Formed on Size and Book-to-Market (2×3) monthly value-weighted returns from Professor Kenneth French’s data library.
# Install/load required packages
if (!require("tidyverse")) install.packages("tidyverse")
if (!require("moments")) install.packages("moments")
library(tidyverse)
library(moments)
# ── Download ──────────────────────────────────────────────────────────────────
url <- "https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/6_Portfolios_2x3_CSV.zip"
tmp <- tempfile(fileext = ".zip")
download.file(url, tmp, mode = "wb")
# The ZIP contains one CSV; read it
raw <- read_lines(unz(tmp, unzip(tmp, list = TRUE)$Name[1]))
# ── Parse: locate the value-weighted monthly returns block ───────────────────
# French CSVs have multiple blocks separated by blank lines.
# The FIRST block is "Average Value Weighted Returns -- Monthly"
start <- which(str_detect(raw, "Average Value Weighted Returns -- Monthly"))[1] + 1
# Find the next blank-line-separated section header
ends <- which(raw == "")
end <- ends[ends > start][1] - 1
data_lines <- raw[start:end]
data_lines <- data_lines[str_detect(data_lines, "^\\s*\\d")] # keep rows starting with a year
# Parse into a data frame
ff6 <- read_csv(paste(data_lines, collapse = "\n"),
col_names = c("date", "SmLo", "SmMe", "SmHi",
"BgLo", "BgMe", "BgHi"),
col_types = cols(.default = col_double())) |>
mutate(date = as.integer(date)) |>
filter(date >= 193001, date <= 201812) # Jan 1930 – Dec 2018
glimpse(ff6)## Rows: 1,068
## Columns: 7
## $ date <int> 193001, 193002, 193003, 193004, 193005, 193006, 193007, 193008, 1…
## $ SmLo <dbl> 6.0309, 1.7589, 8.6803, -7.0960, -3.6140, -17.9836, 6.5234, -3.78…
## $ SmMe <dbl> 9.5193, 1.0717, 11.3312, -1.2542, -2.6937, -16.4522, 3.6401, -1.6…
## $ SmHi <dbl> 8.4726, 4.5687, 10.6873, -3.4819, -2.9869, -19.0393, 2.5703, -2.3…
## $ BgLo <dbl> 7.3577, 3.4688, 6.7576, -2.3380, 0.7015, -17.6952, 4.7126, 1.0212…
## $ BgMe <dbl> 3.3456, 1.8817, 8.4208, -1.7620, -2.2797, -13.1636, 3.5511, -0.66…
## $ BgHi <dbl> 2.8546, 1.2148, 5.3549, -6.6843, -1.4025, -11.8401, 5.2714, -1.61…
# ── Split in half ─────────────────────────────────────────────────────────────
# Total months
n <- nrow(ff6)
half <- floor(n / 2)
first <- ff6[1:half, ]
second <- ff6[(half + 1):n, ]
cat("First half :", first$date[1], "–", first$date[half], "(", half, "months )\n")## First half : 193001 – 197406 ( 534 months )
## Second half: 197407 – 201812 ( 534 months )
# ── Compute statistics for each half ──────────────────────────────────────────
portfolios <- c("SmLo", "SmMe", "SmHi", "BgLo", "BgMe", "BgHi")
compute_stats <- function(df, label) {
df |>
select(all_of(portfolios)) |>
summarise(across(everything(),
list(Mean = mean,
SD = sd,
Skewness = skewness,
Kurtosis = kurtosis),
.names = "{.col}_{.fn}")) |>
pivot_longer(everything(),
names_to = c("Portfolio", "Stat"),
names_sep = "_") |>
mutate(Half = label)
}
stats <- bind_rows(
compute_stats(first, "First half (1930–1974)"),
compute_stats(second, "Second half (1975–2018)")
)
# Wide table for display
stats_wide <- stats |>
pivot_wider(names_from = Stat, values_from = value) |>
arrange(Portfolio, Half)
knitr::kable(stats_wide, digits = 3,
caption = "Descriptive Statistics — Monthly Value-Weighted Returns (%)")| Portfolio | Half | Mean | SD | Skewness | Kurtosis |
|---|---|---|---|---|---|
| BgHi | First half (1930–1974) | 1.187 | 8.911 | 1.769 | 17.468 |
| BgHi | Second half (1975–2018) | 1.145 | 4.887 | -0.517 | 5.805 |
| BgLo | First half (1930–1974) | 0.765 | 5.709 | 0.178 | 9.894 |
| BgLo | Second half (1975–2018) | 0.978 | 4.696 | -0.334 | 4.992 |
| BgMe | First half (1930–1974) | 0.812 | 6.734 | 1.712 | 20.535 |
| BgMe | Second half (1975–2018) | 1.058 | 4.339 | -0.473 | 5.653 |
| SmHi | First half (1930–1974) | 1.484 | 10.206 | 2.288 | 20.076 |
| SmHi | Second half (1975–2018) | 1.425 | 5.499 | -0.464 | 7.305 |
| SmLo | First half (1930–1974) | 0.971 | 8.225 | 1.180 | 12.072 |
| SmLo | Second half (1975–2018) | 0.996 | 6.688 | -0.409 | 5.159 |
| SmMe | First half (1930–1974) | 1.169 | 8.423 | 1.580 | 15.740 |
| SmMe | Second half (1975–2018) | 1.355 | 5.282 | -0.533 | 6.425 |
cat("
**Do the two halves suggest the same return distribution?**
The table above compares the mean, standard deviation, skewness, and excess
kurtosis across the six size/value portfolios for the two sub-periods.
Key observations:
1. **Means differ noticeably across sub-periods.** Small-cap and value portfolios
often show higher average returns in one half than the other, which is
inconsistent with a single stable distribution.
2. **Standard deviations are higher in the first half**, largely due to the
Great Depression and World War II era volatility.
3. **Skewness and kurtosis vary across halves**, indicating the shape of the
return distribution is not constant over time.
**Conclusion:** The split-halves statistics suggest the six portfolios do *not*
come from the same distribution over the entire 1930–2018 period. The first half
is characterised by higher volatility and fatter tails, while the second half
shows more moderate dispersion. This is consistent with structural breaks
(regulatory changes, technological shifts, globalisation) that altered return
dynamics over time.
")Do the two halves suggest the same return distribution?
The table above compares the mean, standard deviation, skewness, and excess kurtosis across the six size/value portfolios for the two sub-periods.
Key observations:
Means differ noticeably across sub-periods. Small-cap and value portfolios often show higher average returns in one half than the other, which is inconsistent with a single stable distribution.
Standard deviations are higher in the first half, largely due to the Great Depression and World War II era volatility.
Skewness and kurtosis vary across halves, indicating the shape of the return distribution is not constant over time.
Conclusion: The split-halves statistics suggest the six portfolios do not come from the same distribution over the entire 1930–2018 period. The first half is characterised by higher volatility and fatter tails, while the second half shows more moderate dispersion. This is consistent with structural breaks (regulatory changes, technological shifts, globalisation) that altered return dynamics over time.
| Action | Probability | Expected Return |
|---|---|---|
| Invest in equities | 0.6 | $50,000 |
| Invest in equities | 0.4 | −$30,000 |
| Invest in risk-free T-bill | 1.0 | $5,000 |
# ── Inputs ────────────────────────────────────────────────────────────────────
p1 <- 0.6; r1 <- 50000 # equity outcome 1
p2 <- 0.4; r2 <- -30000 # equity outcome 2
rf <- 5000 # risk-free return (certain)
# ── Expected return of equities ───────────────────────────────────────────────
E_equity <- p1 * r1 + p2 * r2
cat("Expected return (equities) = $", format(E_equity, big.mark = ","), "\n")## Expected return (equities) = $ 18,000
# ── Risk premium ──────────────────────────────────────────────────────────────
risk_premium <- E_equity - rf
cat("Risk premium = $", format(risk_premium, big.mark = ","), "\n")## Risk premium = $ 13,000
\[ E[R_{\text{equity}}] = 0.6 \times \$50{,}000 + 0.4 \times (-\$30{,}000) = \$18{,}000 \]
\[ \text{Risk Premium} = E[R_{\text{equity}}] - R_f = \$18{,}000 - \$5{,}000 = \mathbf{\$13{,}000} \]
The expected risk premium of investing in equities versus risk-free T-bills is $13,000.
| Asset | Type | Entry Price | Shares | Invested |
|---|---|---|---|---|
| QQQ | ETF | $440.00 | 45 | $19,800 |
| SPY | ETF | $510.00 | 39 | $19,890 |
| AAPL | Stock | $263.75 | 75 | $19,781 |
| NVDA | Stock | $180.90 | 110 | $19,899 |
| JNJ | Stock | $160.00 | 62 | $9,920 |
| KO | Stock | $60.00 | 166 | $9,960 |
| Total | $98,711 |
Cash remaining: $1,289
portfolio <- tibble(
Asset = c("QQQ", "SPY", "AAPL", "NVDA", "JNJ", "KO"),
Type = c("ETF","ETF","Stock","Stock","Stock","Stock"),
Entry_Price = c(440.00, 510.00, 263.75, 180.90, 160.00, 60.00),
Current_Price= c(420.05, 488.65, 257.46, 177.82, 161.00, 63.04),
Shares = c(45, 39, 75, 110, 62, 166)
) |>
mutate(
Cost_Basis = Entry_Price * Shares,
Market_Value = Current_Price * Shares,
Unrealized_PL = Market_Value - Cost_Basis,
Unrealized_PL_pct = round(Unrealized_PL / Cost_Basis * 100, 2)
)
knitr::kable(
portfolio |> select(Asset, Type, Entry_Price, Current_Price,
Shares, Unrealized_PL, Unrealized_PL_pct),
digits = 2,
col.names = c("Asset","Type","Entry ($)","Current ($)",
"Shares","Unreal. P/L ($)","Unreal. P/L (%)"),
caption = "Portfolio Status — March 8, 2026"
)| Asset | Type | Entry (\()| Current (\)) | Shares | Unreal. P/L ($) | Unreal. P/L (%) | |
|---|---|---|---|---|---|---|
| QQQ | ETF | 440.00 | 420.05 | 45 | -897.75 | -4.53 |
| SPY | ETF | 510.00 | 488.65 | 39 | -832.65 | -4.19 |
| AAPL | Stock | 263.75 | 257.46 | 75 | -471.75 | -2.38 |
| NVDA | Stock | 180.90 | 177.82 | 110 | -338.80 | -1.70 |
| JNJ | Stock | 160.00 | 161.00 | 62 | 62.00 | 0.62 |
| KO | Stock | 60.00 | 63.04 | 166 | 504.64 | 5.07 |
##
## Total cost basis : $ 99,250.25
## Total market value: $ 97,275.94
## Total P/L : $ -1,974.31
| Asset | Status | Decision | Rationale |
|---|---|---|---|
| QQQ | Underperformer | Hold | Still above −8% stop-loss; monitoring |
| SPY | Underperformer | Hold | Still above −8% stop-loss; monitoring |
| AAPL | −2.38% | Hold | Well above −10% stop-loss threshold |
| NVDA | −1.72% | Hold | Well above −10% stop-loss threshold |
| JNJ | +0.63% | Long-term hold | Defensive position; stable dividend |
| KO | +5.07% | Long-term hold | Defensive position; brand strength |
Note on current prices: QQQ and SPY have experienced pullbacks from early March highs due to macro headwinds (tariff uncertainty, Fed rate expectations). NVDA and AAPL remain under mild pressure. JNJ and KO are performing in line with their defensive role. No stop-losses have been triggered; all positions are maintained.