5  Risk-Free Rate Construction in Vietnam

Note

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.2 Factor Premiums

In the Fama and French (1993) three-factor model, the market risk premium is:

\[ \text{MKTRF}_t = r_{m,t} - r_{f,t} \tag{5.2}\]

where \(r_{m,t}\) is the value-weighted market return. The size (SMB) and value (HML) premiums are defined as returns on long-short portfolios and do not directly depend on \(r_{f,t}\). However, the intercept (alpha) from a time-series regression of any portfolio’s excess return on the factors does depend on \(r_{f,t}\) through the dependent variable. A biased risk-free rate shifts all alphas uniformly.

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).

Table 5.1: Requirements for a Risk-Free Asset
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:

  1. Short maturity: Minimizes reinvestment risk and term premium contamination. The conventional choice is 1-month maturity.
  2. Government-backed: Eliminates credit risk (in domestic currency).
  3. Actively traded: Ensures that the observed yield reflects current market conditions.
  4. Regular issuance: Provides a continuous time series without gaps.
  5. 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:

Table 5.2: Vietnamese Government Bond 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).

Table 5.3: SBV Policy Rates
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

Table 5.4: Comparison of Risk-Free Rate Proxies in Vietnam
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 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())
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()
Figure 5.1

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)
Table 5.5: Data Source Composition of Blended Risk-Free Rate Series
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_comp

5.4.4 Properties of the Constructed Series

Table 5.6: Summary Statistics of Monthly Risk-Free Rate Proxies
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()
Figure 5.2

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

Table 5.7: Pairwise Correlation of Monthly Risk-Free Rate 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_corr

High 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).

Table 5.8: Consistency Requirements for Excess Returns
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()
Figure 5.3

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.1 Effect on the Equity Premium

Table 5.9: Annualized Equity Premium Under Alternative Risk-Free Proxies
results = []
for proxy_name, proxy_df in rf_proxies.items():
    rf_merge = proxy_df[["date", "rf_monthly"]].rename(
        columns={"rf_monthly": "rf_proxy"}
    )
    merged = market_monthly[["date", "rm"]].merge(rf_merge, on="date", how="inner")
    merged["mktrf_proxy"] = merged["rm"] - merged["rf_proxy"]

    n_months = merged["mktrf_proxy"].count()
    mean_monthly = merged["mktrf_proxy"].mean()
    std_monthly = merged["mktrf_proxy"].std()
    sharpe = mean_monthly / std_monthly if std_monthly > 0 else np.nan
    t_stat = mean_monthly / (std_monthly / np.sqrt(n_months))

    results.append({
        "Proxy": proxy_name.replace("_", " ").title(),
        "N Months": n_months,
        "Mean (% ann.)": round(mean_monthly * 12 * 100, 2),
        "Std (% ann.)": round(std_monthly * np.sqrt(12) * 100, 2),
        "Sharpe (ann.)": round(sharpe * np.sqrt(12), 3),
        "t-stat": round(t_stat, 2)
    })

pd.DataFrame(results).style.hide(axis="index")

5.6.2 Effect on Factor Premiums

Table 5.10: Factor Premium Sensitivity to Risk-Free Proxy (Annualized %)
# Load or construct factor returns
# Assume factors_monthly has: date, smb, hml (these are long-short, rf-independent)
# Only MKTRF changes with the proxy

factors_monthly = pd.read_parquet("data/factors_monthly.parquet")

for proxy_name, proxy_df in rf_proxies.items():
    rf_merge = proxy_df[["date", "rf_monthly"]].rename(
        columns={"rf_monthly": "rf_proxy"}
    )
    factors_merged = factors_monthly.merge(rf_merge, on="date", how="inner")
    factors_merged = factors_merged.merge(
        market_monthly[["date", "rm"]], on="date", how="inner"
    )
    factors_merged[f"mktrf_{proxy_name}"] = (
        factors_merged["rm"] - factors_merged["rf_proxy"]
    )

    mean_mktrf = factors_merged[f"mktrf_{proxy_name}"].mean() * 12 * 100
    mean_smb = factors_merged["smb"].mean() * 12 * 100
    mean_hml = factors_merged["hml"].mean() * 12 * 100

    print(
        f"{proxy_name:>25s}: MKTRF = {mean_mktrf:6.2f}%, "
        f"SMB = {mean_smb:6.2f}%, HML = {mean_hml:6.2f}%"
    )

Note that SMB and HML are constructed as long-short portfolio returns and should be identical regardless of the risk-free proxy. Only MKTRF differs. However, if the researcher uses the risk-free rate to compute individual stock excess returns before sorting into factor portfolios, small differences in sorting may arise.

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.

Table 5.11: Momentum Portfolio Alpha Sensitivity to Risk-Free 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.

Table 5.12: Cost of Equity and Terminal Value Sensitivity to Risk-Free Proxy
# 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")
ImportantKey Finding

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 = params
tau_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}")
Figure 5.4
Tip

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()
Figure 5.5
Note

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).

Table 5.13: Risk-Free Rate Proxies: International Comparison
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 markets

Vietnam’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).

Table 5.14: Best Practices for Risk-Free Rate Construction
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)}")
Tip

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:

  1. 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.

  2. 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%.

  3. 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.

  4. 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.

  5. 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.