import pandas as pd
import numpy as np
# Assume rf_data contains: date, rate_type, rate_annual (annualized, in %)
rf_raw = pd.read_parquet("data/risk_free_rates.parquet")
# Preview available rate types
print("Available rate types:")
print(rf_raw["rate_type"].value_counts())5 Risk-Free Rate Construction in Vietnam
In this chapter, we address a simple but consequential problem: how to construct a risk-free rate series for empirical finance in Vietnam. We evaluate available proxies, such as government bond yields, interbank overnight rates, and State Bank of Vietnam (SBV) policy rates, develop interpolation and frequency-alignment procedures, and quantify the sensitivity of key asset pricing outputs to the choice of risk-free proxy.
The risk-free rate is the most important single number in finance. It anchors excess returns, discount rates, factor premiums, the cost of equity, performance evaluation, and derivative pricing. Despite its foundational role, the risk-free rate is often treated as a given: a number downloaded from a database and plugged into formulas without further thought. In developed markets with deep, liquid government securities markets, this casual approach is usually harmless. In Vietnam, it is not.
Vietnam’s fixed-income market is thin, fragmented, and characterized by irregular issuance of short-term government securities. There is no single, universally accepted risk-free rate analogous to the 1-month Treasury bill rate that anchors virtually all asset pricing research in mature markets. Instead, researchers face a choice among imperfect proxies, each with distinct advantages and limitations. This choice is not innocuous: different proxies can produce meaningfully different excess returns, factor premiums, and valuation estimates.
This chapter develops a systematic approach to risk-free rate construction. We begin with the theoretical requirements for a risk-free asset, then evaluate available Vietnamese proxies against these requirements. We construct monthly risk-free rate series under alternative specifications, demonstrate frequency alignment and interpolation techniques, and conduct sensitivity analysis to quantify how the choice of proxy affects downstream results.
5.1 The Role of the Risk-Free Rate in Finance
5.1.1 Excess Returns
The most fundamental use of the risk-free rate is in the computation of excess returns. The excess return on asset \(i\) in period \(t\) is:
\[ r_{i,t}^{e} = r_{i,t} - r_{f,t} \tag{5.1}\]
where \(r_{i,t}\) is the raw return and \(r_{f,t}\) is the risk-free rate over the same period, in the same currency, and under the same compounding convention. Excess returns isolate the compensation for bearing risk, removing the return that could be earned without risk exposure.
Mismeasurement of \(r_{f,t}\) directly contaminates every excess return observation and, by extension, every quantity derived from excess returns. If \(r_{f,t}\) is systematically biased upward, excess returns are systematically understated, factor premiums are compressed, and the cost of equity is overstated.
5.1.3 Discount Rates and Valuation
The discounted cash flow model values a firm as:
\[ V_0 = \sum_{t=1}^{\infty} \frac{E[CF_t]}{(1 + r_{WACC})^t} \tag{5.3}\]
where the weighted average cost of capital (WACC) depends on the cost of equity, which in turn depends on the risk-free rate through the Capital Asset Pricing Model:
\[ r_{e} = r_f + \beta_i (\bar{r}_m - r_f) \tag{5.4}\]
A 100 basis point error in \(r_f\) flows through to the cost of equity and can change the present value of a long-duration cash flow stream by 10-20%, depending on the duration profile.
5.1.4 Performance Evaluation
Risk-adjusted performance measures such as the Sharpe ratio:
\[ \text{SR} = \frac{\bar{r}_p - \bar{r}_f}{\sigma(r_p - r_f)} \tag{5.5}\]
and Jensen’s alpha:
\[ \alpha = \bar{r}_p - r_f - \hat{\beta}_p (\bar{r}_m - r_f) \tag{5.6}\]
both depend directly on \(r_f\). The Sharpe ratio is particularly sensitive because the denominator (excess return volatility) is also affected by the level and variability of \(r_f\).
5.2 What Does “Risk-Free” Mean in Practice?
A truly risk-free asset must satisfy four conditions simultaneously (Table 5.1).
| Condition | Definition | Practical Challenge |
|---|---|---|
| No default risk | The issuer cannot fail to pay | Only sovereign debt in one’s own currency qualifies |
| Known cash flows | Payoff is certain ex ante | Rules out floating-rate instruments |
| No reinvestment risk | Maturity matches the investment horizon | Requires zero-coupon securities of exact maturity |
| High liquidity | Can be traded at a low cost | Thin government bond markets fail this test |
No real-world asset perfectly satisfies all four conditions. Even in the deepest government bond markets, there is a liquidity premium in off-the-run securities and a convenience yield in on-the-run issues (Krishnamurthy and Vissing-Jorgensen 2012). The practical question is: which available instrument comes closest?
5.2.1 The Ideal Proxy
The ideal risk-free rate proxy for empirical asset pricing research has the following properties:
- Short maturity: Minimizes reinvestment risk and term premium contamination. The conventional choice is 1-month maturity.
- Government-backed: Eliminates credit risk (in domestic currency).
- Actively traded: Ensures that the observed yield reflects current market conditions.
- Regular issuance: Provides a continuous time series without gaps.
- Consistent methodology: Yield computation is unambiguous and comparable over time.
5.3 Available Proxies in Vietnam
Vietnam’s financial infrastructure provides several candidate instruments, none of which perfectly satisfies all criteria. We evaluate each in turn.
5.3.1 Government Bond Yields
The Vietnamese government issues bonds across a range of maturities through the State Treasury and the Vietnam Bond Market (VBM). Key characteristics:
| Feature | Description |
|---|---|
| Issuer | State Treasury of Vietnam |
| Primary market | Auction through HNX |
| Available maturities | 1, 2, 3, 5, 7, 10, 15, 20, 30 years |
| Shortest benchmark | 1-year (sometimes shorter tenors via T-bills) |
| Coupon structure | Fixed, semi-annual |
| Issuance frequency | Regular weekly/bi-weekly auctions |
| Secondary market liquidity | Concentrated in 3-5 year segment; short end is thin |
| Data availability | Available through DataCore.vn |
The primary limitation of government bond yields as a risk-free proxy is the scarcity of short-maturity securities. One-year bonds are the shortest regularly issued benchmark, and their yield includes a term premium that is absent from a true risk-free rate. Treasury bills (maturity < 1 year) are issued irregularly and in small volumes, making them unsuitable as a continuous series.
5.3.2 Interbank Overnight Rate
The Vietnam interbank market sets overnight lending rates between commercial banks. The overnight rate is reported by the SBV.
Advantages: Very short maturity (overnight), high frequency (daily), reflects actual borrowing costs in the financial system.
Limitations: Reflects banking sector credit risk (interbank default risk, though small), can be volatile during liquidity crunches, and does not correspond to a tradeable zero-coupon instrument.
5.3.3 SBV Policy Rates
The State Bank of Vietnam sets several administered rates (Table 5.3).
| Rate | Role | Frequency of Change |
|---|---|---|
| Refinancing rate | Rate at which SBV lends to banks | Infrequent (policy meetings) |
| Discount rate | Rate for rediscounting eligible paper | Infrequent |
| Overnight lending rate | Ceiling for interbank overnight | Infrequent |
| Deposit rate cap | Maximum rate banks can pay on deposits | Infrequent |
Advantages: Stable (changes infrequently), reflects the monetary policy stance, available for the entire sample period.
Limitations: Not a traded return (i.e., no investor can actually earn the policy rate). Represents an administrative target, not a market-clearing price. Responds to macroeconomic conditions with a lag.
5.3.4 Savings Deposit Rates
Commercial banks offer term deposits at rates subject to SBV caps. Short-term (1-month or 3-month) deposit rates are sometimes used as informal risk-free proxies in practitioner contexts.
Advantages: Represent an investable return for small investors, widely available.
Limitations: Subject to bank credit risk, vary across banks, caps create a ceiling that may not reflect true equilibrium rates, not standardized for research use.
5.3.5 Summary Comparison
| Proxy | Default Risk | Maturity Match | Continuity | Liquidity | Recommended Use |
|---|---|---|---|---|---|
| 1-Year Government Bond | Minimal (sovereign) | Poor (1Y vs 1M) | Good | Moderate | Annual/quarterly analysis |
| Treasury Bills (< 1Y) | Minimal (sovereign) | Good (when available) | Poor (irregular issuance) | Low | When available, preferred |
| Interbank Overnight Rate | Low (interbank) | Very short (overnight) | Excellent | High (interbank) | Daily/monthly analysis |
| SBV Refinancing Rate | None (administered) | N/A (not traded) | Excellent | N/A | Long-run comparisons only |
| 1-Month Bank Deposit Rate | Low-moderate (bank) | Good (1M) | Good | N/A | Practitioner DCF |
5.4 Constructing the Risk-Free Rate Series
We now construct alternative monthly risk-free rate series and examine their properties.
5.4.1 Loading and Cleaning Rate Data
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 4.5))
colors = {
"govt_bond_1y": "#2C73D2",
"interbank_overnight": "#FF6B6B",
"sbv_refinancing": "#5DCEAF",
"tbill_3m": "#FFB347",
"deposit_1m": "#B19CD9"
}
for rate_type, color in colors.items():
subset = rf_raw[rf_raw["rate_type"] == rate_type].sort_values("date")
if len(subset) > 0:
ax.plot(
subset["date"], subset["rate_annual"],
label=rate_type.replace("_", " ").title(),
color=color, linewidth=1.2, alpha=0.85
)
ax.set_ylabel("Annualized Rate (%)")
ax.set_xlabel("")
ax.legend(frameon=False, fontsize=9, loc="upper right")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()5.4.2 Frequency Alignment
Asset pricing tests require monthly risk-free rates. Raw data may arrive at daily, weekly, or irregular frequencies. We use the following conversion logic:
Daily to monthly: Take the average of daily rates within each month, then convert from annualized to monthly.
Irregular to monthly: For series with gaps (e.g., treasury bills), forward-fill the most recent observation, then average within each month.
Annualized to monthly: Under simple compounding, \(r_f^{monthly} = r_f^{annual} / 12\). Under continuous compounding, \(r_f^{monthly} = r_f^{annual} / 12\) (since continuous rates are additive). We use simple compounding for consistency with the convention that stock returns are computed as arithmetic returns.
# Construct monthly risk-free rates from each proxy
def construct_monthly_rf(rf_raw, rate_type, method="mean"):
"""
Convert raw rate data to monthly frequency.
Parameters
----------
rf_raw : pd.DataFrame
Raw rate data with columns: date, rate_type, rate_annual
rate_type : str
Which rate proxy to use
method : str
Aggregation method: 'mean', 'last', or 'first'
Returns
-------
pd.DataFrame
Monthly risk-free rate with columns: date, rf_monthly
"""
subset = (
rf_raw[rf_raw["rate_type"] == rate_type]
.sort_values("date")
.set_index("date")
)
# Forward-fill gaps (for irregular series)
subset = subset.resample("D").ffill()
# Aggregate to monthly
if method == "mean":
monthly = subset.resample("ME")["rate_annual"].mean()
elif method == "last":
monthly = subset.resample("ME")["rate_annual"].last()
else:
monthly = subset.resample("ME")["rate_annual"].first()
monthly = monthly.reset_index()
monthly.columns = ["date", "rf_annual"]
# Convert annualized rate (%) to monthly decimal
monthly["rf_monthly"] = monthly["rf_annual"] / 100 / 12
return monthly[["date", "rf_monthly", "rf_annual"]]
# Construct series for each proxy
rf_proxies = {}
for proxy in ["govt_bond_1y", "interbank_overnight", "sbv_refinancing"]:
rf_proxies[proxy] = construct_monthly_rf(rf_raw, proxy)
rf_proxies[proxy]["proxy"] = proxy
rf_all = pd.concat(rf_proxies.values(), ignore_index=True)5.4.3 Handling Missing Data and Structural Breaks
Vietnamese rate data may contain gaps due to market closures, reporting changes, or the introduction of new instruments. We handle these systematically:
# Check coverage for each proxy
coverage = (
rf_all
.groupby("proxy")
.agg(
start_date=("date", "min"),
end_date=("date", "max"),
n_months=("rf_monthly", "count"),
n_missing=("rf_monthly", lambda x: x.isna().sum()),
avg_rate_pct=("rf_annual", "mean")
)
.round(2)
)
print(coverage)For periods where the primary proxy is unavailable, we construct a blended series using a priority hierarchy:
def construct_blended_rf(rf_proxies, priority=None):
"""
Construct a blended monthly risk-free rate using proxy priority.
Priority order (default):
1. Treasury bills (shortest maturity, sovereign)
2. Interbank overnight (short maturity, high frequency)
3. 1-year government bond (sovereign, regular)
4. SBV refinancing rate (fallback)
"""
if priority is None:
priority = [
"tbill_3m",
"interbank_overnight",
"govt_bond_1y",
"sbv_refinancing"
]
# Create full date range
all_dates = pd.date_range(
start=min(df["date"].min() for df in rf_proxies.values()),
end=max(df["date"].max() for df in rf_proxies.values()),
freq="ME"
)
blended = pd.DataFrame({"date": all_dates})
blended["rf_monthly"] = np.nan
blended["source"] = ""
for proxy in priority:
if proxy in rf_proxies:
proxy_df = rf_proxies[proxy][["date", "rf_monthly"]].rename(
columns={"rf_monthly": f"rf_{proxy}"}
)
blended = blended.merge(proxy_df, on="date", how="left")
# Fill missing values from this proxy
mask = blended["rf_monthly"].isna() & blended[f"rf_{proxy}"].notna()
blended.loc[mask, "rf_monthly"] = blended.loc[mask, f"rf_{proxy}"]
blended.loc[mask, "source"] = proxy
blended = blended.drop(columns=[f"rf_{proxy}"])
return blended
rf_blended = construct_blended_rf(rf_proxies)source_comp = (
rf_blended
.groupby("source")
.agg(
n_months=("date", "count"),
pct=("date", lambda x: len(x) / len(rf_blended) * 100)
)
.round(1)
.sort_values("n_months", ascending=False)
)
source_comp5.4.4 Properties of the Constructed Series
rf_wide = rf_all.pivot_table(
index="date", columns="proxy", values="rf_monthly"
)
summary = rf_wide.describe(percentiles=[0.10, 0.25, 0.50, 0.75, 0.90]).T
summary = summary[["mean", "std", "min", "10%", "50%", "90%", "max"]]
summary.columns = [
"Mean", "Std", "Min", "P10", "Median", "P90", "Max"
]
# Convert to annualized percentage for interpretability
(summary * 12 * 100).round(2)fig, axes = plt.subplots(2, 1, figsize=(8, 6), sharex=True)
# Level
for proxy, color in [
("govt_bond_1y", "#2C73D2"),
("interbank_overnight", "#FF6B6B"),
("sbv_refinancing", "#5DCEAF")
]:
subset = rf_proxies[proxy].sort_values("date")
axes[0].plot(
subset["date"], subset["rf_monthly"] * 100,
label=proxy.replace("_", " ").title(),
color=color, linewidth=1
)
axes[0].set_ylabel("Monthly Rate (%)")
axes[0].legend(frameon=False, fontsize=9)
axes[0].spines["top"].set_visible(False)
axes[0].spines["right"].set_visible(False)
# Pairwise spread: govt bond - interbank
merged = rf_proxies["govt_bond_1y"][["date", "rf_monthly"]].merge(
rf_proxies["interbank_overnight"][["date", "rf_monthly"]],
on="date", suffixes=("_bond", "_interbank")
)
merged["spread"] = (merged["rf_monthly_bond"] - merged["rf_monthly_interbank"]) * 100
axes[1].fill_between(
merged["date"], merged["spread"], 0,
where=merged["spread"] > 0, alpha=0.4, color="#2C73D2", label="Bond > Interbank"
)
axes[1].fill_between(
merged["date"], merged["spread"], 0,
where=merged["spread"] <= 0, alpha=0.4, color="#FF6B6B", label="Bond < Interbank"
)
axes[1].axhline(0, color="black", linewidth=0.5)
axes[1].set_ylabel("Spread (% monthly)")
axes[1].set_xlabel("")
axes[1].legend(frameon=False, fontsize=9)
axes[1].spines["top"].set_visible(False)
axes[1].spines["right"].set_visible(False)
plt.tight_layout()
plt.show()The spread between proxies is informative. A consistently positive spread (bond yield > interbank rate) reflects the term premium embedded in the 1-year bond. Periods where the interbank rate spikes above the bond yield typically correspond to liquidity crunches in the banking system.
5.4.5 Correlation Across Proxies
rf_corr = rf_wide.corr().round(3)
rf_corr.index = [x.replace("_", " ").title() for x in rf_corr.index]
rf_corr.columns = [x.replace("_", " ").title() for x in rf_corr.columns]
rf_corrHigh correlation (> 0.8) between proxies suggests that the level and direction of interest rate movements are captured similarly by all proxies. Low correlation would indicate that the choice of proxy introduces substantial idiosyncratic variation into excess returns.
5.5 Excess Return Construction
With the risk-free rate series in hand, we now construct excess returns for individual stocks and for the market portfolio.
5.5.1 Matching Conventions
Excess return construction requires strict consistency across three dimensions (Table 5.8).
| Dimension | Requirement | Common Error |
|---|---|---|
| Currency | Same currency for \(r_i\) and \(r_f\) | Using USD rate for VND-denominated returns |
| Frequency | Same holding period | Using annualized \(r_f\) with monthly \(r_i\) |
| Compounding | Same convention | Mixing log and arithmetic returns |
# Load monthly stock returns
stock_returns = pd.read_parquet("data/monthly_returns.parquet")
# Assume columns: symbol, date (month-end), ret
# Merge with risk-free rate
# Use the blended series as the baseline
rf_for_merge = rf_blended[["date", "rf_monthly"]].rename(
columns={"rf_monthly": "rf"}
)
stock_returns = stock_returns.merge(rf_for_merge, on="date", how="left")
# Compute excess returns
stock_returns["ret_excess"] = stock_returns["ret"] - stock_returns["rf"]5.5.2 Market Excess Return
# Value-weighted market return
market_monthly = (
stock_returns
.groupby("date")
.apply(
lambda g: np.average(
g["ret"].dropna(),
weights=g["mktcap"].loc[g["ret"].dropna().index]
) if g["ret"].dropna().shape[0] > 0 else np.nan,
include_groups=False
)
.reset_index(name="rm")
)
market_monthly = market_monthly.merge(rf_for_merge, on="date", how="left")
market_monthly["mktrf"] = market_monthly["rm"] - market_monthly["rf"]fig, ax = plt.subplots(figsize=(8, 3.5))
ax.bar(
market_monthly["date"], market_monthly["mktrf"] * 100,
color=np.where(market_monthly["mktrf"] >= 0, "#2C73D2", "#FF6B6B"),
width=25, alpha=0.8
)
ax.axhline(0, color="black", linewidth=0.5)
ax.set_ylabel("Market Excess Return (%)")
ax.set_xlabel("")
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()5.6 Sensitivity Analysis: How Much Does the Proxy Choice Matter?
This is the central empirical question of the chapter. If different risk-free proxies produce essentially the same downstream results, the choice is inconsequential. If they produce different results, researchers must justify their choice and report robustness.
5.6.3 Effect on Alpha Estimates
The choice of risk-free proxy affects alpha estimates for any portfolio evaluated against a factor model. We illustrate this by estimating the alpha of a momentum portfolio under each proxy.
import statsmodels.api as sm
# Assume momentum_ret contains: date, mom_ret (raw return of WML portfolio)
momentum_ret = pd.read_parquet("data/momentum_returns.parquet")
alpha_results = []
for proxy_name, proxy_df in rf_proxies.items():
rf_merge = proxy_df[["date", "rf_monthly"]].rename(
columns={"rf_monthly": "rf_proxy"}
)
merged = (
momentum_ret
.merge(rf_merge, on="date", how="inner")
.merge(market_monthly[["date", "rm"]], on="date", how="inner")
.merge(factors_monthly[["date", "smb", "hml"]], on="date", how="inner")
)
merged["mom_excess"] = merged["mom_ret"] - merged["rf_proxy"]
merged["mktrf"] = merged["rm"] - merged["rf_proxy"]
X = sm.add_constant(merged[["mktrf", "smb", "hml"]])
y = merged["mom_excess"]
model = sm.OLS(y, X).fit(cov_type="HAC", cov_kwds={"maxlags": 6})
alpha_results.append({
"Proxy": proxy_name.replace("_", " ").title(),
"Alpha (% monthly)": round(model.params["const"] * 100, 3),
"t-stat": round(model.tvalues["const"], 2),
"R²": round(model.rsquared, 3)
})
pd.DataFrame(alpha_results).style.hide(axis="index")5.6.4 Effect on Valuation
To illustrate the valuation impact, consider a simple DCF exercise where the cost of equity is estimated via the CAPM.
# Example: firm with beta = 1.0, expected CF = 1 billion VND, growth = 3%
beta_example = 1.0
cf = 1e9 # VND
growth = 0.03
valuation_results = []
for proxy_name, proxy_df in rf_proxies.items():
rf_ann = proxy_df["rf_annual"].dropna().iloc[-12:].mean() / 100 # Latest year avg
rf_merge = proxy_df[["date", "rf_monthly"]].rename(
columns={"rf_monthly": "rf_proxy"}
)
mkt_merged = market_monthly[["date", "rm"]].merge(
rf_merge, on="date", how="inner"
)
mkt_merged["mktrf"] = mkt_merged["rm"] - mkt_merged["rf_proxy"]
erp = mkt_merged["mktrf"].mean() * 12 # Annualized equity premium
cost_equity = rf_ann + beta_example * erp
terminal_value = cf / (cost_equity - growth) if cost_equity > growth else np.nan
valuation_results.append({
"Proxy": proxy_name.replace("_", " ").title(),
"Rf (% ann.)": round(rf_ann * 100, 2),
"ERP (% ann.)": round(erp * 100, 2),
"Cost of Equity (%)": round(cost_equity * 100, 2),
"Terminal Value (B VND)": round(terminal_value / 1e9, 1) if terminal_value else "N/A"
})
pd.DataFrame(valuation_results).style.hide(axis="index")Even modest differences in the risk-free rate (50-150 basis points across proxies) can produce terminal value differences of 10-30%. Researchers and practitioners must document their risk-free rate choice explicitly and report sensitivity to alternatives.
5.7 Term Structure Considerations
When longer-horizon discount rates are needed (e.g., for multi-year DCF or cost of capital estimation), the risk-free rate should be maturity-matched. This requires constructing a yield curve from available government bond data.
5.7.1 Yield Curve Estimation
Gürkaynak, Sack, and Wright (2007) develop a parametric approach to yield curve estimation using the Nelson and Siegel (1987) and Svensson (1994) models. The Nelson-Siegel model parameterizes the instantaneous forward rate as:
\[ f(\tau) = \beta_0 + \beta_1 \exp\left(-\frac{\tau}{\lambda}\right) + \beta_2 \frac{\tau}{\lambda} \exp\left(-\frac{\tau}{\lambda}\right) \tag{5.7}\]
where \(\tau\) is the maturity, \(\beta_0\) is the long-run level, \(\beta_1\) determines the slope, \(\beta_2\) determines the curvature, and \(\lambda\) controls the location of the hump.
The corresponding yield is:
\[ y(\tau) = \beta_0 + \beta_1 \frac{1 - \exp(-\tau/\lambda)}{\tau/\lambda} + \beta_2 \left[\frac{1 - \exp(-\tau/\lambda)}{\tau/\lambda} - \exp(-\tau/\lambda)\right] \tag{5.8}\]
from scipy.optimize import minimize
def nelson_siegel_yield(tau, beta0, beta1, beta2, lam):
"""Nelson-Siegel yield curve model."""
tau_lam = tau / lam
factor1 = (1 - np.exp(-tau_lam)) / tau_lam
factor2 = factor1 - np.exp(-tau_lam)
return beta0 + beta1 * factor1 + beta2 * factor2
def fit_nelson_siegel(maturities, yields):
"""Fit Nelson-Siegel model to observed yields."""
def objective(params):
beta0, beta1, beta2, lam = params
if lam <= 0:
return 1e10
fitted = nelson_siegel_yield(maturities, beta0, beta1, beta2, lam)
return np.sum((yields - fitted) ** 2)
result = minimize(
objective,
x0=[yields[-1], yields[0] - yields[-1], 0, 2.0],
method="Nelder-Mead",
options={"maxiter": 10000}
)
return result.x
# Example: fit to latest available government bond yields
# Assume govt_yields contains: date, maturity_years, yield_pct
govt_yields = pd.read_parquet("data/govt_bond_yields.parquet")
latest_date = govt_yields["date"].max()
latest_yields = govt_yields[govt_yields["date"] == latest_date].sort_values("maturity_years")
maturities = latest_yields["maturity_years"].values
yields = latest_yields["yield_pct"].values
params = fit_nelson_siegel(maturities, yields)
beta0, beta1, beta2, lam = paramstau_fine = np.linspace(0.25, 30, 200)
fitted_yields = nelson_siegel_yield(tau_fine, *params)
fig, ax = plt.subplots(figsize=(7, 4))
ax.scatter(
maturities, yields, color="#FF6B6B", s=60, zorder=5,
label="Observed yields", edgecolors="white"
)
ax.plot(
tau_fine, fitted_yields, color="#2C73D2", linewidth=2,
label="Nelson-Siegel fit"
)
ax.set_xlabel("Maturity (Years)")
ax.set_ylabel("Yield (%)")
ax.legend(frameon=False)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()
print(f"Nelson-Siegel parameters:")
print(f" β₀ (level) = {beta0:.4f}")
print(f" β₁ (slope) = {beta1:.4f}")
print(f" β₂ (curvature) = {beta2:.4f}")
print(f" λ (decay) = {lam:.4f}")The Nelson-Siegel yield curve allows extraction of a risk-free rate at any maturity. For monthly asset pricing, evaluate the curve at \(\tau = 1/12\) (one month). For DCF valuation with a 10-year horizon, evaluate at \(\tau = 10\). This maturity-matching approach is superior to using a single proxy for all purposes.
5.7.2 Extracting the Short-Rate from the Yield Curve
# Extract 1-month rate from Nelson-Siegel curve at each date
def extract_ns_short_rate(govt_yields, target_maturity=1/12):
"""
For each date, fit Nelson-Siegel and extract the yield
at the target maturity.
"""
dates = govt_yields["date"].unique()
short_rates = []
for d in dates:
obs = govt_yields[govt_yields["date"] == d].sort_values("maturity_years")
if len(obs) < 3: # Need at least 3 points to fit
short_rates.append({"date": d, "rf_ns": np.nan})
continue
try:
params = fit_nelson_siegel(
obs["maturity_years"].values,
obs["yield_pct"].values
)
rf_ns = nelson_siegel_yield(target_maturity, *params)
short_rates.append({"date": d, "rf_ns": rf_ns})
except Exception:
short_rates.append({"date": d, "rf_ns": np.nan})
return pd.DataFrame(short_rates)
ns_short_rates = extract_ns_short_rate(govt_yields)5.8 Real vs. Nominal Risk-Free Rates
For certain applications, particularly long-horizon valuation and real return analysis, the real (inflation-adjusted) risk-free rate is more appropriate than the nominal rate. The Fisher equation relates them:
\[ r_f^{real} \approx r_f^{nominal} - \pi^{e} \tag{5.9}\]
where \(\pi^e\) is expected inflation. In practice, we can use realized CPI inflation as a proxy for expected inflation (under the assumption of rational expectations, or as an ex-post adjustment).
# Load CPI data
# Assume cpi_data contains: date, cpi_index (or inflation_mom for month-over-month)
cpi_data = pd.read_parquet("data/cpi_monthly.parquet")
cpi_data = cpi_data.sort_values("date")
cpi_data["inflation_monthly"] = cpi_data["cpi_index"].pct_change()
rf_real = rf_blended[["date", "rf_monthly"]].merge(
cpi_data[["date", "inflation_monthly"]], on="date", how="inner"
)
rf_real["rf_real"] = rf_real["rf_monthly"] - rf_real["inflation_monthly"]fig, ax = plt.subplots(figsize=(8, 4))
ax.plot(
rf_real["date"], rf_real["rf_monthly"] * 100,
color="#2C73D2", label="Nominal", linewidth=1
)
ax.plot(
rf_real["date"], rf_real["rf_real"] * 100,
color="#FF6B6B", label="Real", linewidth=1
)
ax.axhline(0, color="black", linewidth=0.5, linestyle="--")
ax.set_ylabel("Monthly Rate (%)")
ax.set_xlabel("")
ax.legend(frameon=False)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
plt.tight_layout()
plt.show()In periods of high inflation, the real risk-free rate can be substantially negative. This has implications for real excess return computation and for interpreting the equity premium in real terms. A negative real risk-free rate implies that nominal government securities do not preserve purchasing power, which strengthens the case for equity investment from a real-return perspective.
5.9 International Comparison
It is instructive to compare Vietnam’s risk-free rate environment with that of other emerging and developed markets to contextualize the magnitudes involved (Table 5.13).
| Country | Typical Proxy | Short-Term Maturity Available | Typical Level (%, 2020-2024) |
|---|---|---|---|
| Vietnam | Interbank overnight / 1Y govt bond | Limited (<1Y sparse) | 2.5-6.0 |
| Thailand | 1-day bilateral repurchase rate | Yes (1-day repo) | 0.5-2.5 |
| Indonesia | Bank Indonesia 7-day reverse repo rate | Yes (7-day, 1M) | 3.5-6.0 |
| Philippines | 91-day T-bill | Yes (91-day T-bill) | 1.5-6.0 |
| India | 91-day T-bill | Yes (91-day T-bill) | 3.5-7.0 |
| South Korea | 91-day CD rate | Yes (91-day CD) | 1.0-3.5 |
| Japan | Uncollateralized overnight call rate | Yes (overnight) | -0.1-0.1 |
# get the risk-free rate of Asian marketsVietnam’s risk-free rate environment is characterized by relatively high nominal rates (reflecting inflation and growth dynamics), limited availability of very-short-maturity sovereign instruments, and greater reliance on interbank rates as the operational proxy. This is broadly similar to other ASEAN frontier markets but contrasts sharply with developed Asian markets, where deep government securities markets provide clean short-term benchmarks.
5.10 Best Practices Checklist
Based on the analysis in this chapter, we summarize the recommended practices for risk-free rate construction in Vietnamese financial research (Table 5.14).
| Practice | Rationale |
|---|---|
| Use the shortest available maturity | Minimizes term premium contamination |
| Match frequency to return data | Avoids compounding mismatches between rf and stock returns |
| Use the same compounding convention | Arithmetic returns require arithmetic rf conversion |
| Document the proxy and source explicitly | Enables replication; different proxies give different results |
| Report sensitivity to alternative proxies | Demonstrates robustness of conclusions |
| Avoid forward-looking policy rates | Policy rates are not investable returns |
| Handle gaps with documented interpolation | Forward-fill, then average within month; document procedure |
| Use Nelson-Siegel for maturity matching | Enables extraction of any-maturity rate from sparse data |
| Consider real rates for long-horizon analysis | Nominal rates overstate real compensation in high-inflation periods |
| Maintain a single consistent series per study | Mixing proxies across periods introduces structural breaks |
5.11 Saving the Risk-Free Rate for Downstream Use
The final step is to save the constructed risk-free rate series for use in subsequent chapters.
# Save all variants for flexibility
rf_output = rf_blended[["date", "rf_monthly", "source"]].copy()
rf_output["rf_annual_pct"] = rf_output["rf_monthly"] * 12 * 100
# Also save individual proxies
for proxy_name, proxy_df in rf_proxies.items():
col_name = f"rf_{proxy_name}"
rf_output = rf_output.merge(
proxy_df[["date", "rf_monthly"]].rename(
columns={"rf_monthly": col_name}
),
on="date",
how="left"
)
# Save to parquet for use in later chapters
rf_output.to_parquet("data/risk_free_rate.parquet", index=False)
print(f"Risk-free rate series saved: {len(rf_output)} months")
print(f"Date range: {rf_output['date'].min()} to {rf_output['date'].max()}")
print(f"\nColumns: {list(rf_output.columns)}")By saving the risk-free rate as a separate, well-documented file, all downstream chapters can merge it consistently. This avoids the common pitfall of reconstructing the risk-free rate differently in different analyses within the same study.
5.12 Summary
This chapter has established that risk-free rate construction is a first-order modeling decision in Vietnamese financial research, not a technical afterthought. The key takeaways are:
No perfect proxy exists in Vietnam. The interbank overnight rate, 1-year government bond yield, and SBV policy rate each have distinct strengths and limitations. A blended series using a documented priority hierarchy provides the most robust baseline.
The choice of proxy matters quantitatively. Different proxies can shift the estimated equity premium by 50-200 basis points annually, alter portfolio alphas, and change DCF terminal values by 10-30%.
Frequency alignment and compounding conventions must be handled with care. Converting annualized rates to monthly requires specifying the compounding convention. Gaps in the data require documented interpolation.
The Nelson-Siegel yield curve model enables the extraction of any-maturity risk-free rate from sparse government bond data, which is particularly valuable for maturity-matched discount rate estimation.
Sensitivity analysis is mandatory. Any study that reports results under a single risk-free proxy without reporting robustness to alternatives has an unquantified source of specification uncertainty.