14  Liquidity and Turnover Measures

Note

In this chapter, we construct a comprehensive suite of liquidity measures for the Vietnamese equity market, validate them against each other and against known benchmarks, test whether liquidity is priced in the cross-section of stock returns, examine commonality in liquidity, and analyze how liquidity conditions vary over time and across market regimes.

Liquidity (i.e., the ability to trade quickly at low cost without moving the price) is arguably the single most important practical consideration for anyone working with Vietnamese equity data. A factor premium that looks attractive in a frictionless backtest may be completely unimplementable if the long and short legs load on illiquid stocks whose prices move against you when you trade. Conversely, a genuine liquidity premium (i.e., compensation for bearing the risk that a stock will be hard to sell when you need to) is one of the most robust and theoretically grounded anomalies in asset pricing.

The challenge is that liquidity is inherently multidimensional and difficult to measure. In developed markets with continuous limit order books and sub-second trade reporting, researchers can observe bid-ask spreads, market depth, and price impact directly. In Vietnam, microstructure data at this granularity are limited: HOSE operates a periodic call auction at open and close with continuous matching in between, the tick size is coarse relative to price levels, and many stocks trade so infrequently that the concept of a “quoted spread” is meaningful only on days when the stock actually trades. This forces researchers to rely on low-frequency proxies computed from daily price and volume data.

This chapter constructs the major liquidity proxies used in the academic literature, validates them in the Vietnamese context, and demonstrates their use in asset pricing and portfolio construction.

14.1 Theoretical Foundations

14.1.1 Why Liquidity Matters

Liquidity affects asset prices through at least three channels:

  1. Level effect. Investors demand compensation for the expected cost of trading. Amihud and Mendelson (1986) show that stocks with higher bid-ask spreads earn higher expected returns, with the premium being an increasing function of the investor’s holding period. In equilibrium, illiquid stocks must offer higher expected returns to compensate for higher round-trip trading costs.
  2. Risk effect. Liquidity is time-varying and co-moves across stocks. Pástor and Stambaugh (2003) show that stocks whose returns are more sensitive to aggregate liquidity shocks earn higher expected returns. Acharya and Pedersen (2005) formalize this in a liquidity-adjusted CAPM where the required return includes a premium for bearing liquidity risk (i.e., the risk that the stock becomes illiquid precisely when the investor needs to sell).
  3. Commonality effect. Chordia, Roll, and Subrahmanyam (2000) document that individual stock liquidity co-moves strongly with market-wide liquidity. Brunnermeier and Pedersen (2009) explain this through a “liquidity spiral”: when asset values fall, margin constraints tighten, forcing leveraged investors to sell, which reduces market liquidity, which depresses prices further. This mechanism is particularly relevant in Vietnam, where retail investors with margin accounts are the dominant trading population.

14.1.2 Liquidity Dimensions

Kyle (1985) identifies three dimensions of liquidity:

  1. Tightness. The cost of turning around a position quickly, which is measured by the bid-ask spread.
  2. Depth. The volume that can be traded without moving the price, which is related to price impact.
  3. Resiliency. The speed at which prices recover from uninformative order flow shocks.

No single measure captures all three dimensions. Goyenko, Holden, and Trzcinka (2009) and Fong, Holden, and Trzcinka (2017) systematically evaluate which low-frequency proxies best capture each dimension by benchmarking against high-frequency measures. Their key finding: the Amihud (2002) measure best captures the price impact dimension, the Roll (1984) estimator and Corwin and Schultz (2012) spread best capture tightness, and the Lesmond, Ogden, and Trzcinka (1999) zero-return measure captures a blend of transaction costs and information asymmetry. For emerging markets specifically, Fong, Holden, and Trzcinka (2017) recommend the Amihud measure and the Closing Percent Quoted Spread as the most reliable proxies.

14.2 Data Construction

Import libraries and configure environment
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.api as sm
from scipy import stats
from linearmodels.panel import PanelOLS
from linearmodels.asset_pricing import LinearFactorModel
import warnings
warnings.filterwarnings('ignore')

plt.rcParams.update({
    'figure.figsize': (12, 6),
    'figure.dpi': 150,
    'font.size': 11,
    'axes.spines.top': False,
    'axes.spines.right': False
})
Load daily and monthly data
from datacore import DataCoreClient

client = DataCoreClient()

# Daily trading data
daily = client.get_daily_prices(
    exchanges=['HOSE', 'HNX'],
    start_date='2008-01-01',
    end_date='2024-12-31',
    include_delisted=True,
    fields=[
        'ticker', 'date', 'open', 'high', 'low', 'close',
        'adjusted_close', 'volume', 'turnover_value',
        'market_cap', 'shares_outstanding', 'free_float_pct',
        'bid', 'ask', 'foreign_buy_volume', 'foreign_sell_volume'
    ]
)

# Monthly aggregates
monthly = client.get_monthly_returns(
    exchanges=['HOSE', 'HNX'],
    start_date='2008-01-01',
    end_date='2024-12-31',
    include_delisted=True,
    fields=[
        'ticker', 'month_end', 'monthly_return', 'market_cap',
        'volume_avg_20d', 'turnover_value_avg_20d',
        'n_trading_days', 'n_zero_volume_days'
    ]
)

# Firm characteristics for cross-sectional tests
fundamentals = client.get_fundamentals(
    exchanges=['HOSE', 'HNX'],
    start_date='2008-01-01',
    end_date='2024-12-31',
    include_delisted=True,
    frequency='annual',
    fields=[
        'ticker', 'fiscal_year', 'total_assets', 'total_equity',
        'net_income', 'revenue', 'book_equity'
    ]
)

# Factor returns for asset pricing tests
factors = client.get_factor_returns(
    market='vietnam',
    start_date='2008-01-01',
    end_date='2024-12-31',
    factors=['mkt_excess', 'smb', 'hml', 'wml']
)

daily['date'] = pd.to_datetime(daily['date'])
daily = daily.sort_values(['ticker', 'date'])

print(f"Daily observations: {daily.shape[0]:,}")
print(f"Monthly observations: {monthly.shape[0]:,}")
print(f"Unique tickers: {daily['ticker'].nunique()}")
Compute daily returns and basic trading statistics
# Daily returns
daily['daily_return'] = (
    daily.groupby('ticker')['adjusted_close'].pct_change()
)
daily['abs_return'] = daily['daily_return'].abs()
daily['log_return'] = np.log(
    daily['adjusted_close'] / daily.groupby('ticker')['adjusted_close'].shift(1)
)

# Turnover ratio (shares traded / shares outstanding)
daily['turnover_ratio'] = daily['volume'] / daily['shares_outstanding']

# Zero indicators
daily['zero_return'] = (daily['daily_return'] == 0).astype(int)
daily['zero_volume'] = (daily['volume'] == 0).astype(int)

# VND turnover (in billions)
daily['turnover_vnd_bn'] = daily['turnover_value'] / 1e9

print("Daily Return Summary:")
print(daily['daily_return'].describe().round(6))
print(f"\nZero-return days: {daily['zero_return'].mean():.1%}")
print(f"Zero-volume days: {daily['zero_volume'].mean():.1%}")

14.3 Constructing Liquidity Measures

We construct seven liquidity proxies that span the dimensions of tightness, depth, and resiliency. Each is computed at the firm-month level, producing a panel that can be merged with monthly return data for cross-sectional tests.

14.3.1 Amihud Illiquidity Ratio

The Amihud (2002) illiquidity measure is the ratio of absolute daily return to daily volume (in VND):

\[ \text{ILLIQ}_{i,m} = \frac{1}{D_{i,m}} \sum_{d=1}^{D_{i,m}} \frac{|R_{i,d}|}{\text{DVOL}_{i,d}} \tag{14.1}\]

where \(|R_{i,d}|\) is the absolute daily return, \(\text{DVOL}_{i,d}\) is VND trading volume on day \(d\), and \(D_{i,m}\) is the number of trading days with positive volume in month \(m\). Higher values indicate greater illiquidity.

The Amihud measure captures the price impact dimension of liquidity. It is grounded in the Kyle (1985) model where the parameter \(\lambda\) (Kyle’s lambda) measures the price impact of order flow: \(\Delta p = \lambda \cdot Q\). The Amihud ratio is a daily-frequency analog of \(\lambda\).

Compute monthly Amihud illiquidity ratio
def compute_amihud(daily_df, min_days=10):
    """
    Compute the Amihud (2002) illiquidity ratio at the firm-month level.
    
    Excludes zero-volume days. Requires at least min_days observations
    with positive volume per firm-month.
    """
    df = daily_df[daily_df['volume'] > 0].copy()
    df['month'] = df['date'].dt.to_period('M')
    
    # |Return| / VND Volume
    df['illiq_daily'] = df['abs_return'] / df['turnover_value']
    
    # Remove extreme outliers (top 0.1% within each month)
    df['illiq_daily'] = df.groupby('month')['illiq_daily'].transform(
        lambda x: x.clip(upper=x.quantile(0.999))
    )
    
    # Aggregate to firm-month
    amihud = (
        df.groupby(['ticker', 'month'])
        .agg(
            amihud_raw=('illiq_daily', 'mean'),
            n_positive_vol_days=('illiq_daily', 'count')
        )
        .reset_index()
    )
    
    # Filter: require minimum trading days
    amihud = amihud[amihud['n_positive_vol_days'] >= min_days]
    
    # Log transform (raw Amihud is heavily right-skewed)
    amihud['amihud'] = np.log(1 + amihud['amihud_raw'] * 1e6)
    
    # Convert period to timestamp for merging
    amihud['month_end'] = amihud['month'].dt.to_timestamp('M')
    
    return amihud[['ticker', 'month_end', 'amihud', 'amihud_raw',
                    'n_positive_vol_days']]

amihud_monthly = compute_amihud(daily)
print(f"Amihud observations: {len(amihud_monthly):,}")
print(f"\nLog Amihud distribution:")
print(amihud_monthly['amihud'].describe().round(3))

14.3.1.1 The Original 2002 Definition

Equation 38.2 is the modern form of the measure, not the one Amihud (2002) actually estimated. The original is annual rather than monthly:

\[ \text{ILLIQ}_{i,y} = \frac{1}{D_{i,y}}\sum_{d=1}^{D_{i,y}} \frac{|R_{i,y,d}|}{\text{VOLD}_{i,y,d}} \tag{14.2}\]

where \(\text{VOLD}_{i,y,d}\) is dollar volume on day \(d\) of year \(y\) and \(D_{i,y}\) is the number of days in year \(y\) with both return and volume data. The differences from Equation 38.2 are not cosmetic, and each one is a modelling decision that later authors quietly changed:

  1. Annual, not monthly. Amihud’s object of interest is expected illiquidity, which he models as slow-moving. He regresses monthly returns in year \(y\) on \(\text{ILLIQ}\) measured over year \(y-1\), so the measure is a persistent firm characteristic rather than a monthly state variable. The monthly convention now dominant in the literature trades persistence for responsiveness, and inherits substantially more measurement noise per observation: averaging roughly 21 daily ratios instead of 250 inflates the sampling error of the average by about \(\sqrt{250/21} \approx 3.5\) times.

  2. NYSE only. Amihud deliberately excluded NASDAQ, because dealer-market interdealer trades double-count volume and would contaminate the denominator. The Vietnamese analogue is not a dealer market but the put-through (negotiated block) segment and, on UPCoM, extremely episodic trading; both distort \(\text{DVOL}\) in ways the original design was constructed to avoid.

  3. Screens. He required more than 200 days of data in the year, an end-of-year price above $5, and available shares outstanding, and he trimmed the highest and lowest 1% of the annual \(\text{ILLIQ}\) cross-section as outliers. The price screen removes stocks where tick discreteness dominates the numerator, a screen with real bite in Vietnam, where a large share of the listed universe trades below 10,000 VND on a 10 VND tick.

  4. The scaling factor. Amihud multiplies by \(10^6\), which is a unit convention tied to dollars, not a property of the estimator. We return to this below, because it is the single most common source of non-comparable Amihud numbers in the literature.

  5. Mean adjustment. This is the most frequently dropped ingredient of the original paper and the one that matters most for Vietnam. Because dollar volume grows secularly with inflation, market growth, and rising turnover, raw \(\text{ILLIQ}\) carries a mechanical downward trend that has nothing to do with liquidity improving. Amihud therefore also works with the mean-adjusted measure

    \[ \text{ILLIQMA}_{i,y} = \frac{\text{ILLIQ}_{i,y}}{\text{AILLIQ}_{y}}, \qquad \text{AILLIQ}_{y} = \frac{1}{N_y}\sum_{i=1}^{N_y}\text{ILLIQ}_{i,y} \tag{14.3}\]

    which is unit-free and detrended by construction. In a market where VND trading value grew by an order of magnitude over the sample period, using raw \(\text{ILLIQ}\) in any pooled or time-series specification without period fixed effects will find a “liquidity improvement” that is an artefact of the denominator.

His two headline results are worth separating, because they are often conflated. In the cross-section, expected illiquidity is positively priced: stocks with higher prior-year \(\text{ILLIQMA}\) earn higher subsequent returns. In the time series, unexpected illiquidity — the innovation from an AR(1) fitted to \(\text{AILLIQ}_y\) — is associated with negative contemporaneous returns, because a liquidity shock is capitalized immediately as a price drop even as it raises expected future returns. A test that mixes the two will find the sign confusing.

14.3.1.2 Precursor and Theory: Why a Return-to-Volume Ratio

The measure did not appear from nowhere. Amihud and Mendelson (1986) had already shown, theoretically and empirically, that expected returns increase in the quoted bid-ask spread, because investors must be compensated for the cost of a round trip. The problem with that result is data: quoted spreads require a quote history, which for most markets and most of history does not exist. The 2002 measure is best read as an answer to the question “what can be computed from daily returns and volume alone that behaves like a transaction cost?” — the same question that motivates the Roll, Corwin–Schultz, and Lesmond estimators developed elsewhere in this chapter. Amihud, Mendelson, and Pedersen (2005) survey the resulting literature.

The theoretical warrant comes from Kyle (1985). In Kyle’s model the price impact of a signed order flow \(Q\) is \(\Delta p = \lambda Q\), so \(\lambda = |\Delta p| / |Q|\) is exactly a return-to-volume ratio. Equation 38.2 is that ratio computed with unsigned order flow, which is all daily data provides. Brennan and Subrahmanyam (1996) show directly that price-impact estimates constructed from intraday signed order flow carry a return premium, and Goyenko, Holden, and Trzcinka (2009) and Goyenko and Ukhov (2009) later establish that the Amihud ratio is a good low-frequency proxy for those high-frequency price-impact benchmarks. Fong, Holden, and Trzcinka (2017) extend the horse race to global data and find it performs well outside the United States, which is why it is the workhorse in emerging-market research and why this chapter leans on it.

14.3.1.3 The Variant Lineage

Table 14.1 collects the transformations in common use. All of them are monotone in raw \(\text{ILLIQ}\) within a period, which has an important consequence developed below.

Table 14.1: Transformations of the Amihud illiquidity ratio in common use. All are strictly increasing in raw \(\text{ILLIQ}\) within a given period.
Variant Definition Origin / typical use
Raw Mean of \(\lvert R_d\rvert / \text{DVOL}_d\) The estimator itself; almost never used directly
Amihud-scaled \(\text{ILLIQ}\times 10^6\) Amihud (2002), dollar volume in dollars
Mean-adjusted \(\text{ILLIQ}_{i}/\overline{\text{ILLIQ}}\) Amihud (2002); unit-free, detrended (Equation 14.3)
Log \(\log(\text{ILLIQ})\) or \(\log(1+\text{ILLIQ})\) Variance stabilization; the convention used in the code above
Square root \(\sqrt{\text{ILLIQ}}\) Hasbrouck (2009); closer in units to an effective cost
Normalized cost \(\min(a + b\,\text{ILLIQ}\,P^M_{t-1},\,\bar{c})\) Acharya and Pedersen (2005); maps to a percentage cost, capped
Within-period rank Percentile of \(\text{ILLIQ}\) in period \(t\) Karolyi, Lee, and Dijk (2012), Amihud et al. (2015); cross-market comparability

Two of these deserve elaboration. The Acharya and Pedersen (2005) normalization exists because their model requires illiquidity denominated as a percentage transaction cost, so that it can be subtracted from returns. Their calibration \(\min(0.25 + 0.30\,\text{ILLIQ}\,P^M_{t-1},\,30)\) scales by the growth of the market portfolio, which absorbs the same volume trend that \(\text{ILLIQMA}\) absorbs, and caps the result at 30% to keep the extreme tail from dominating. The constants are fitted to US dollar-volume data in a specific era and cannot be transposed to VND. The code below shows the formula failing in both directions depending only on the volume unit: with volume in VND every stock is assigned the intercept cost of 0.25% with zero cross-sectional dispersion, and with volume in billions of VND every stock is pinned at the 30% cap, again with zero dispersion. Only one intermediate unit happens to produce variation, and there is nothing economic about that choice. The within-period rank transform used in the international liquidity literature (Karolyi, Lee, and Dijk 2012; Amihud et al. 2015) exists for exactly this reason. It is the only transformation in the table that is invariant to the currency, the volume unit, and the era.

A further refinement worth knowing about is Amihud and Noh (2021), who revisit the original results with a tradeable illiquid-minus-liquid factor and a conditional specification. Their construction is the closest published relative of the liquidity factor built at the end of this chapter.

14.3.1.4 Scaling Is Not Innocuous

The following chunk is self-contained and executes. It builds a synthetic daily panel with known, constant true illiquidity and then shows what the variant choice does to the numbers.

All Amihud variants, and what scaling does to them
def amihud_variants(daily, ret="ret", dvol="dvol", scale=1e6, min_days=15):
    """
    Compute the Amihud (2002) ratio and its common transformations from a
    daily panel with columns [ticker, date, ret, dvol].

    `dvol` is currency volume in whatever unit the caller supplies; `scale`
    is Amihud's units convention, which is *not* unit-free (see below).
    """
    df = daily.loc[daily[dvol] > 0].copy()
    df["period"] = df["date"].dt.to_period("M")
    df["illiq_d"] = df[ret].abs() / df[dvol]

    p = (df.groupby(["ticker", "period"])
           .agg(illiq=("illiq_d", "mean"), n_days=("illiq_d", "size"))
           .reset_index())
    p = p.loc[p["n_days"] >= min_days].copy()

    p["illiq_scaled"] = p["illiq"] * scale                    # Amihud's x10^6
    p["illiq_ma"] = p["illiq"] / p.groupby("period")["illiq"].transform("mean")
    p["log_illiq"] = np.log(p["illiq_scaled"])                # variance stabilizing
    p["sqrt_illiq"] = np.sqrt(p["illiq_scaled"])              # Hasbrouck (2009)
    p["illiq_rank"] = p.groupby("period")["illiq"].rank(pct=True)
    return p


# A synthetic panel: 40 stocks, two years, TRUE illiquidity constant over time,
# but market-wide VND volume growing at 35% per year.
rng_v = np.random.default_rng(11)
dates_v = pd.bdate_range("2023-01-02", "2024-12-31")
tickers_v = [f"S{i:02d}" for i in range(40)]
level_v = rng_v.lognormal(np.log(3e9), 1.1, size=len(tickers_v))  # VND/day
years_v = (dates_v - dates_v[0]).days / 365.25

demo = pd.concat([
    pd.DataFrame({
        "ticker": t,
        "date": dates_v,
        "ret": rng_v.normal(0, 0.022, len(dates_v)),
        "dvol": L * np.exp(rng_v.normal(0, 0.6, len(dates_v))) * np.exp(0.35 * years_v),
    })
    for t, L in zip(tickers_v, level_v)
], ignore_index=True)

# (1) The x10^6 convention is a currency convention, not a normalization.
print("Median ILLIQ x 10^6, identical economics, three volume units:")
for unit, divisor in [("VND", 1.0), ("million VND", 1e6), ("USD @ 25,400", 25_400.0)]:
    med = amihud_variants(demo.assign(dvol=demo["dvol"] / divisor))["illiq_scaled"].median()
    print(f"  DVOL in {unit:<14s} {med:>12.6g}")

# (2) Mean adjustment removes the volume trend; raw scaling does not.
v = amihud_variants(demo)
ts = v.groupby("period")[["illiq_scaled", "illiq_ma"]].median()
first, last = ts.iloc[0], ts.iloc[-1]
print("\nTrue illiquidity is constant; volume grows 35%/yr. Median over the panel:")
for col, name in [("illiq_scaled", "ILLIQ x 10^6"), ("illiq_ma", "ILLIQMA     ")]:
    print(f"  {name}: {first[col]:>10.4g} -> {last[col]:>10.4g} "
          f"({100 * (last[col] / first[col] - 1):+.1f}%)")

# (3) Every variant is monotone within a period, so ranks are identical.
cols_v = ["illiq_scaled", "illiq_ma", "log_illiq", "sqrt_illiq", "illiq_rank"]
within = (v.groupby("period")[["illiq"] + cols_v]
            .corr(method="spearman").xs("illiq", level=1)[cols_v].mean())
print("\nMean within-period Spearman rank correlation with raw ILLIQ:")
print(within.round(4).to_string())

# (4) But the distributions they induce are very different.
print("\nPooled distribution shape:")
print(v[["illiq_scaled", "log_illiq", "sqrt_illiq"]]
      .agg(["skew", "kurt"]).round(3).to_string())

# (5) Acharya-Pedersen constants are calibrated to US dollar volume, so the
#     same formula degenerates in opposite directions depending on the unit.
print("\nAcharya-Pedersen cost min(0.25 + 0.30*ILLIQ*1e6, 30) by volume unit:")
for unit, divisor in [("VND", 1.0), ("million VND", 1e6), ("billion VND", 1e9)]:
    s = amihud_variants(demo.assign(dvol=demo["dvol"] / divisor))["illiq_scaled"]
    ap = np.minimum(0.25 + 0.30 * s, 30.0)
    print(f"  DVOL in {unit:<12s} mean={ap.mean():8.4f}  sd={ap.std():8.4f}  "
          f"at cap={100 * (ap >= 30.0).mean():5.1f}%")
Median ILLIQ x 10^6, identical economics, three volume units:
  DVOL in VND             5.40237e-06
  DVOL in million VND         5.40237
  DVOL in USD @ 25,400        0.13722

True illiquidity is constant; volume grows 35%/yr. Median over the panel:
  ILLIQ x 10^6:  7.324e-06 ->  4.068e-06 (-44.5%)
  ILLIQMA     :     0.6307 ->      0.641 (+1.6%)

Mean within-period Spearman rank correlation with raw ILLIQ:
illiq_scaled    1.0
illiq_ma        1.0
log_illiq       1.0
sqrt_illiq      1.0
illiq_rank      1.0

Pooled distribution shape:
      illiq_scaled  log_illiq  sqrt_illiq
skew         2.986      0.147       1.447
kurt        11.810     -0.360       2.519

Acharya-Pedersen cost min(0.25 + 0.30*ILLIQ*1e6, 30) by volume unit:
  DVOL in VND          mean=  0.2500  sd=  0.0000  at cap=  0.0%
  DVOL in million VND  mean=  2.8306  sd=  3.0728  at cap=  0.0%
  DVOL in billion VND  mean= 30.0000  sd=  0.0000  at cap=100.0%

Five lessons follow, and they are the reason this subsection exists.

The \(\times 10^6\) factor carries no information. The same economic illiquidity reports as roughly \(5\times10^{-6}\), \(5\), or \(0.13\) depending only on whether volume is booked in VND, million VND, or USD. Published “Amihud illiquidity of 0.8” figures are therefore uncomparable across papers unless the volume unit is stated, and it usually is not. State the unit; better, report a transformation that does not depend on it.

Raw levels move with the denominator’s trend. In the simulation, true illiquidity is constant by construction, yet the median raw measure falls by roughly 45% over two years purely because volume grew. \(\text{ILLIQMA}\) moves by a few percent, which is sampling noise. Vietnam’s trading value grew far faster than 35% per year in several stretches of the post-2015 period, so this is a first-order concern for any statement of the form “liquidity in Vietnam improved over the sample.”

For within-period sorts, the variant choice does not matter at all. Every transformation in Table 14.1 is strictly increasing within a period, so the mean within-period rank correlation is exactly 1.0 and the portfolio sorts later in this chapter would be numerically identical under any of them. This is worth knowing because it bounds how much of the literature’s variant-shopping can possibly matter: it can matter only through specifications that use levels — pooled Fama-MacBeth without period fixed effects, time-series regressions, and anything comparing magnitudes across periods or markets.

The transformation matters for distributional reasons instead. Pooled skewness falls from roughly 3.0 to 0.1 and excess kurtosis from roughly 11.8 to \(-0.4\) under the log transform. Since Fama-MacBeth slopes are OLS coefficients and OLS is not robust to a right tail this heavy, the log transform is doing real work in the cross-sectional regressions: not fixing the ranks, but stopping a handful of firm-months from determining the slope.

Borrowed calibrations do not survive the trip. The Acharya and Pedersen (2005) cost mapping is the clearest case. Under VND volume it assigns every firm-month the intercept, 0.25%, with a cross-sectional standard deviation of exactly zero; under billions of VND it pins all of them at the 30% cap, again with zero dispersion. In neither case does it fail loudly — it returns a plausible-looking percentage cost that happens to carry no information whatsoever about which stock is illiquid. Any published constant of the form \(a + b\,\text{ILLIQ}\) inherits the units of the data it was fitted to, and must be re-estimated rather than copied.

To apply the same function to the chapter’s actual panel:

Apply the variant constructor to the Vietnamese daily panel
# `daily` carries turnover_value in VND; express it in billions so that the
# resulting ratio reads as "return per billion VND traded".
amihud_all = amihud_variants(
    daily.rename(columns={"turnover_value": "dvol"}).assign(
        dvol=lambda d: d["dvol"] / 1e9
    ),
    scale=100.0,          # report as PERCENT price move per billion VND
    min_days=10,
)
print(amihud_all[["illiq_scaled", "illiq_ma", "log_illiq"]].describe().round(4))

Setting scale=100.0 with volume in billions VND gives the measure a reading that survives translation: the percentage price move associated with one billion VND of trading. This is the convention we recommend for Vietnamese work, and it should always be stated alongside the number.

14.3.1.5 What Is Actually Being Priced: The Lou–Shu Critique

There is a deeper objection to Equation 38.2 than any question of scaling, and it bears directly on the high-frequency refinement developed in Section 14.4.

Lou and Shu (2017) decompose the Amihud ratio into the part attributable to the denominator and the part attributable to genuine return-per-unit-volume price impact. Their finding is uncomfortable for the standard interpretation: the return premium associated with \(\text{ILLIQ}\) is driven by the trading volume component rather than by the price-impact component. Put bluntly, sorting on \(\text{ILLIQ}\) is close to sorting on inverse dollar volume, and the premium it earns may reflect whatever inverse dollar volume proxies for — investor recognition, neglect, retail attention — rather than compensation for price impact.

This has two implications for the book.

The first is a warning. Where later sections of this chapter report a liquidity premium from \(\text{ILLIQ}\) sorts, the correct interpretation is “a premium associated with the Amihud characteristic,” not “compensation for price impact.” Distinguishing the two requires the decomposition, and it requires controlling for dollar volume directly.

The second is a genuinely open question that the realized estimator is positioned to answer. The price-impact content of \(\text{ILLIQ}\) lives in the numerator, and Section 14.4 shows that the daily numerator \(|R_d|\) carries roughly 76% relative measurement error while the denominator \(\text{DVOL}_d\) is observed essentially without error. A horse race between a numerator measured with 76% noise and a denominator measured with none is not a fair test of which component matters: classical errors-in-variables attenuates the numerator’s estimated contribution toward zero regardless of its true economic role. Whether the Lou–Shu result survives a numerator measured with the roughly sevenfold greater precision of Lacava, Ranaldo, and Santucci de Magistris (2026) is, as far as we are aware, untested. We flag it as a research opportunity rather than a result, and we note that Vietnam is a poor place to attempt it, for the intraday-data reasons set out in Section 14.4.

14.3.1.6 Which Variant This Book Uses

For concreteness, the conventions adopted in the remainder of this chapter and the rest of the book:

  1. Monthly frequency, for consistency with the monthly return panel, accepting the roughly 3.5-fold increase in sampling error relative to Amihud’s annual construction.
  2. Volume in billions VND, positive-volume days only, minimum ten valid days per firm-month.
  3. Log transform for regression work, following the code accompanying Equation 38.2 above, because the pooled distribution is far too skewed for OLS in levels.
  4. Within-month ranks or quintiles for portfolio sorts, which as shown above are invariant to every other choice on this list.
  5. Mean adjustment whenever levels are compared across time, which in practice means any specification without month fixed effects.

The measure is also reported without transformation in the summary statistics, so that readers can convert to any other convention.

14.3.2 Zero-Return Days (Lesmond Measure)

Lesmond, Ogden, and Trzcinka (1999) propose using the proportion of zero-return days as a measure of transaction costs. The intuition is that if the true value change on a given day is smaller than the round-trip transaction cost, a rational marginal investor will not trade, and the observed return will be zero. Thus, the zero-return proportion is an increasing function of effective transaction costs.

Lesmond (2005) validates this measure for emerging markets and finds it strongly correlated with explicit cost measures. In Vietnam, where zero-return days are common (as documented in the previous chapter), this measure has particular relevance.

\[ \text{ZeroRet}_{i,m} = \frac{\text{Number of days with } R_{i,d} = 0}{D_{i,m}} \tag{14.4}\]

Compute monthly zero-return proportion
def compute_zero_return(daily_df):
    """
    Compute the Lesmond et al. (1999) zero-return measure
    at the firm-month level.
    """
    df = daily_df.copy()
    df['month'] = df['date'].dt.to_period('M')
    
    zero_ret = (
        df.groupby(['ticker', 'month'])
        .agg(
            n_days=('daily_return', 'count'),
            n_zero_return=('zero_return', 'sum'),
            n_zero_volume=('zero_volume', 'sum')
        )
        .reset_index()
    )
    
    zero_ret['zero_return_pct'] = (
        zero_ret['n_zero_return'] / zero_ret['n_days']
    )
    zero_ret['zero_volume_pct'] = (
        zero_ret['n_zero_volume'] / zero_ret['n_days']
    )
    
    zero_ret['month_end'] = zero_ret['month'].dt.to_timestamp('M')
    
    return zero_ret[['ticker', 'month_end', 'zero_return_pct',
                      'zero_volume_pct', 'n_days']]

zero_monthly = compute_zero_return(daily)
print(f"Zero-return observations: {len(zero_monthly):,}")
print(f"\nZero-return proportion distribution:")
print(zero_monthly['zero_return_pct'].describe().round(3))

14.3.3 Turnover Ratio

Share turnover (i.e., daily volume divided by shares outstanding) measures trading activity rather than trading cost. Datar, Naik, and Radcliffe (1998) use turnover as a liquidity proxy and document a negative cross-sectional relationship between turnover and expected returns, consistent with the liquidity premium hypothesis.

\[ \text{Turn}_{i,m} = \frac{1}{D_{i,m}} \sum_{d=1}^{D_{i,m}} \frac{\text{Volume}_{i,d}}{\text{SharesOut}_{i,d}} \tag{14.5}\]

Compute monthly average turnover ratio
def compute_turnover(daily_df):
    """Compute average daily turnover ratio at the firm-month level."""
    df = daily_df.copy()
    df['month'] = df['date'].dt.to_period('M')
    
    turnover = (
        df.groupby(['ticker', 'month'])
        .agg(
            turnover_mean=('turnover_ratio', 'mean'),
            turnover_sum=('turnover_ratio', 'sum'),
            volume_mean=('volume', 'mean'),
            dvol_mean=('turnover_value', 'mean')
        )
        .reset_index()
    )
    
    # Log transform for cross-sectional normality
    turnover['log_turnover'] = np.log(
        turnover['turnover_mean'].clip(lower=1e-8)
    )
    turnover['log_dvol'] = np.log(
        turnover['dvol_mean'].clip(lower=1)
    )
    
    turnover['month_end'] = turnover['month'].dt.to_timestamp('M')
    
    return turnover[['ticker', 'month_end', 'turnover_mean',
                      'log_turnover', 'log_dvol']]

turnover_monthly = compute_turnover(daily)
print(f"Turnover observations: {len(turnover_monthly):,}")
print(f"\nLog turnover distribution:")
print(turnover_monthly['log_turnover'].describe().round(3))

14.3.4 Roll Spread Estimator

Roll (1984) derives an implicit bid-ask spread from the serial covariance of price changes. Under the assumptions that the true value follows a random walk and that observed prices bounce between the bid and ask:

\[ \text{Roll}_{i,m} = \begin{cases} 2\sqrt{-\text{Cov}(\Delta P_{i,d}, \Delta P_{i,d-1})} & \text{if } \text{Cov} < 0 \\ 0 & \text{if } \text{Cov} \geq 0 \end{cases} \tag{14.6}\]

where \(\Delta P_{i,d} = P_{i,d} - P_{i,d-1}\). The measure is intuitive: the bid-ask bounce creates negative serial correlation in transaction prices, and the magnitude of this negative correlation reflects the spread.

Compute monthly Roll (1984) effective spread estimator
def compute_roll_spread(daily_df, min_days=15):
    """
    Compute the Roll (1984) effective spread from serial
    covariance of daily price changes.
    """
    df = daily_df.copy()
    df['month'] = df['date'].dt.to_period('M')
    df['price_change'] = df.groupby('ticker')['adjusted_close'].diff()
    df['price_change_lag'] = df.groupby('ticker')['price_change'].shift(1)
    
    def roll_estimate(group):
        if len(group) < min_days:
            return np.nan
        cov = group['price_change'].cov(group['price_change_lag'])
        if cov < 0:
            spread = 2 * np.sqrt(-cov)
            # Normalize by average price
            avg_price = group['adjusted_close'].mean()
            return spread / avg_price if avg_price > 0 else np.nan
        else:
            return 0.0
    
    roll = (
        df.dropna(subset=['price_change', 'price_change_lag'])
        .groupby(['ticker', 'month'])
        .apply(roll_estimate)
        .reset_index(name='roll_spread')
    )
    
    roll['month_end'] = roll['month'].dt.to_timestamp('M')
    
    return roll[['ticker', 'month_end', 'roll_spread']]

roll_monthly = compute_roll_spread(daily)
print(f"Roll spread observations: {len(roll_monthly):,}")
print(f"\nRoll spread distribution:")
print(roll_monthly['roll_spread'].describe().round(4))

14.3.5 Corwin-Schultz High-Low Spread

Corwin and Schultz (2012) estimate the effective spread from daily high and low prices. The key insight is that daily high and low prices contain information about both volatility and the spread—the high is typically a buy and the low a sell, so the high-low range reflects both true volatility and the bid-ask spread. By comparing one-day and two-day high-low ranges, the method separates the two components:

\[ \hat{S}_{i,m} = \frac{2(e^{\hat{\alpha}} - 1)}{1 + e^{\hat{\alpha}}} \tag{14.7}\]

where:

\[ \hat{\alpha} = \frac{\sqrt{2\hat{\beta}} - \sqrt{\hat{\beta}}}{3 - 2\sqrt{2}} - \sqrt{\frac{\hat{\gamma}}{3 - 2\sqrt{2}}} \tag{14.8}\]

with \(\hat{\beta}\) and \(\hat{\gamma}\) computed from one-day and two-day log high-low ratios.

Compute the Corwin-Schultz (2012) high-low spread estimator
def compute_corwin_schultz(daily_df, min_days=15):
    """
    Compute the Corwin and Schultz (2012) bid-ask spread
    estimator from daily high and low prices.
    """
    df = daily_df.copy()
    df['month'] = df['date'].dt.to_period('M')
    
    # Log high-low ratio
    df['log_hl'] = np.log(df['high'] / df['low'])
    df['log_hl_sq'] = df['log_hl'] ** 2
    
    # Two-day high and low
    df['high_2d'] = df.groupby('ticker')['high'].transform(
        lambda x: x.rolling(2).max()
    )
    df['low_2d'] = df.groupby('ticker')['low'].transform(
        lambda x: x.rolling(2).min()
    )
    df['log_hl_2d'] = np.log(df['high_2d'] / df['low_2d'])
    df['log_hl_2d_sq'] = df['log_hl_2d'] ** 2
    
    def cs_estimate(group):
        if len(group) < min_days:
            return np.nan
        
        beta = group['log_hl_sq'].mean() + group['log_hl_sq'].shift(1).mean()
        beta = group[['log_hl_sq']].rolling(2).sum().mean().values[0]
        gamma = group['log_hl_2d_sq'].mean()
        
        k = np.sqrt(2) - 1
        denom = 3 - 2 * np.sqrt(2)
        
        term1 = np.sqrt(max(beta, 0)) / denom
        if beta > 0:
            alpha_est = (np.sqrt(2 * beta) - np.sqrt(beta)) / denom
            alpha_est -= np.sqrt(max(gamma / denom, 0))
        else:
            alpha_est = 0
        
        # Spread estimate
        if alpha_est > 0:
            spread = 2 * (np.exp(alpha_est) - 1) / (1 + np.exp(alpha_est))
        else:
            spread = 0
        
        return min(spread, 0.20)  # Cap at 20% (sanity check)
    
    cs = (
        df.dropna(subset=['log_hl', 'log_hl_2d'])
        .groupby(['ticker', 'month'])
        .apply(cs_estimate)
        .reset_index(name='cs_spread')
    )
    
    cs['month_end'] = cs['month'].dt.to_timestamp('M')
    
    return cs[['ticker', 'month_end', 'cs_spread']]

cs_monthly = compute_corwin_schultz(daily)
print(f"Corwin-Schultz observations: {len(cs_monthly):,}")
print(f"\nCS spread distribution:")
print(cs_monthly['cs_spread'].describe().round(4))

14.3.6 Quoted Bid-Ask Spread

When bid and ask quotes are available, the quoted percentage spread provides a direct measure of tightness:

\[ \text{PQSPR}_{i,m} = \frac{1}{D_{i,m}} \sum_{d=1}^{D_{i,m}} \frac{\text{Ask}_{i,d} - \text{Bid}_{i,d}}{(\text{Ask}_{i,d} + \text{Bid}_{i,d})/2} \tag{14.9}\]

Compute monthly average quoted percentage spread
def compute_quoted_spread(daily_df):
    """Compute average quoted percentage spread at the firm-month level."""
    df = daily_df[
        (daily_df['bid'] > 0) & (daily_df['ask'] > 0) &
        (daily_df['ask'] >= daily_df['bid'])
    ].copy()
    
    df['month'] = df['date'].dt.to_period('M')
    df['pqspr'] = (
        (df['ask'] - df['bid']) / ((df['ask'] + df['bid']) / 2)
    )
    
    # Winsorize extreme values
    df['pqspr'] = df['pqspr'].clip(upper=df['pqspr'].quantile(0.999))
    
    spread = (
        df.groupby(['ticker', 'month'])
        .agg(
            quoted_spread=('pqspr', 'mean'),
            n_quotes=('pqspr', 'count')
        )
        .reset_index()
    )
    
    spread['month_end'] = spread['month'].dt.to_timestamp('M')
    
    return spread[['ticker', 'month_end', 'quoted_spread', 'n_quotes']]

quoted_monthly = compute_quoted_spread(daily)
print(f"Quoted spread observations: {len(quoted_monthly):,}")
print(f"\nQuoted spread distribution:")
print(quoted_monthly['quoted_spread'].describe().round(4))

14.3.7 Kyle’s Lambda (Price Impact Regression)

We estimate Kyle’s lambda (i.e., the price impact per unit of signed order flow) using a daily regression:

\[ R_{i,d} = \alpha_i + \lambda_i \cdot \text{Sign}(R_{i,d}) \cdot \sqrt{\text{Volume}_{i,d}} + \varepsilon_{i,d} \tag{14.10}\]

This is an adaptation of the Hasbrouck (2009) effective cost measure. The coefficient \(\lambda_i\) measures how much prices move per unit of (unsigned, square-rooted) volume.

Estimate Kyle’s lambda from daily price-volume regressions
def compute_kyle_lambda(daily_df, min_days=15):
    """
    Estimate Kyle's lambda (price impact per unit order flow)
    from daily return-on-signed-volume regressions.
    """
    df = daily_df[daily_df['volume'] > 0].copy()
    df['month'] = df['date'].dt.to_period('M')
    
    # Signed square-root volume (sign inferred from return)
    df['signed_sqrt_vol'] = (
        np.sign(df['daily_return']) * np.sqrt(df['volume'])
    )
    
    def estimate_lambda(group):
        if len(group) < min_days:
            return np.nan
        y = group['daily_return'].values
        x = group['signed_sqrt_vol'].values
        x = sm.add_constant(x)
        try:
            model = sm.OLS(y, x).fit()
            lam = model.params[1]
            return max(lam, 0)  # Lambda should be non-negative
        except Exception:
            return np.nan
    
    kyle = (
        df.groupby(['ticker', 'month'])
        .apply(estimate_lambda)
        .reset_index(name='kyle_lambda')
    )
    
    kyle['log_kyle'] = np.log(kyle['kyle_lambda'].clip(lower=1e-10))
    kyle['month_end'] = kyle['month'].dt.to_timestamp('M')
    
    return kyle[['ticker', 'month_end', 'kyle_lambda', 'log_kyle']]

kyle_monthly = compute_kyle_lambda(daily)
print(f"Kyle lambda observations: {len(kyle_monthly):,}")
print(f"\nLog Kyle lambda distribution:")
print(kyle_monthly['log_kyle'].describe().round(3))

14.4 Realized Illiquidity: A High-Frequency Refinement of Amihud

14.4.1 Motivation

Of the seven proxies constructed above, the Amihud (2002) ratio in Equation 38.2 is the one this book leans on hardest: it is the sorting variable in the portfolio sorts and Fama-MacBeth regressions below, the input to the commonality regressions, and the basis of the tradeable liquidity factor constructed at the end of this chapter. It is also the measure that Fong, Holden, and Trzcinka (2017) recommend specifically for emerging markets. It is therefore worth asking how accurately it actually measures what it claims to measure.

The answer is: far less accurately than its ubiquity suggests, and for a reason that has nothing to do with the volume denominator. The numerator \(|R_{i,d}|\) is a single draw used to summarize the entire day’s price movement. As a statistic, it is nearly unbiased for the day’s volatility but extraordinarily noisy — we quantify this below at roughly a 76% relative standard deviation per day. Every unit of that noise propagates into portfolio sorts as classical errors-in-variables attenuation, flattening measured liquidity premia toward zero.

Lacava, Ranaldo, and Santucci de Magistris (2026) address exactly this. They define realized illiquidity as the ratio of realized volatility to trading volume and introduce the realized Amihud, which replaces the single absolute daily return with a high-frequency estimate of the same quantity. They show it robustly captures cumulative intraday price sensitivity to trading in the presence of stochastic volatility, microstructure noise, and information jumps; document heterogeneous clustering and leverage effects in the resulting series; and find that it predicts short-term returns. This section develops the estimator in the book’s notation, derives why the accuracy gain is as large as it is, and sets out what it costs to implement on HOSE.

14.4.2 Setup and Notation

Let \(p_{i,t}\) denote the efficient log price of stock \(i\) at time \(t\), evolving over trading day \(d\) as an Itô semimartingale:

\[ dp_{i,t} = \mu_{i,t}\,dt + \sigma_{i,t}\,dW_{i,t} + dJ_{i,t} \tag{14.11}\]

where \(\sigma_{i,t}\) is a càdlàg stochastic volatility process, \(W_{i,t}\) a standard Brownian motion, and \(J_{i,t}\) a finite-activity jump process capturing discrete information arrivals. The object that summarizes the day’s price variation is the integrated variance

\[ \text{IV}_{i,d} = \int_{d-1}^{d} \sigma_{i,s}^2\,ds \tag{14.12}\]

Partition the continuous trading day into \(M\) intervals and write the intraday log returns as \(r_{i,d,j}\), \(j = 1,\dots,M\), so that the daily return satisfies \(R_{i,d} = \sum_{j=1}^{M} r_{i,d,j}\). As before, \(\text{DVOL}_{i,d}\) is VND trading volume on day \(d\) and \(D_{i,m}\) the number of positive-volume days in month \(m\).

The realized variance and bipower variation are

\[ \text{RV}_{i,d} = \sum_{j=1}^{M} r_{i,d,j}^2, \qquad \text{BV}_{i,d} = \frac{\pi}{2}\sum_{j=2}^{M} \left| r_{i,d,j} \right|\left| r_{i,d,j-1} \right| \tag{14.13}\]

Both are consistent for \(\text{IV}_{i,d}\) as \(M \to \infty\) in the absence of jumps (Barndorff-Nielsen and Shephard 2002; Andersen et al. 2003); \(\text{BV}_{i,d}\) remains consistent with jumps present, which is what makes it useful below (Barndorff-Nielsen and Shephard 2004).

14.4.3 Assumptions

(A1) Semimartingale efficient price. The efficient log price follows Equation 14.11 with \(\int_{d-1}^{d}\sigma_{i,s}^4\,ds < \infty\).

Reading. Prices are driven by a diffusion with possibly random, possibly persistent volatility plus occasional jumps. This is weak and generally uncontroversial. It would be violated by a price process with infinite-activity jumps of high enough intensity, or by a market so thin that no continuous-time approximation is meaningful — the latter is a live concern for small-cap HOSE names.

(A2) Negligible microstructure noise at the sampling frequency. Observed prices satisfy \(\tilde{p}_{i,t} = p_{i,t} + u_{i,t}\), and the sampling interval is coarse enough that \(2M\,\mathbb{V}[u_{i,t}]\) is small relative to \(\text{IV}_{i,d}\).

Reading. Bid-ask bounce, tick discreteness, and stale quotes all inject noise into observed prices. Because \(\text{RV}\) sums squared returns, this noise accumulates with \(M\) rather than averaging out — sampling too finely makes the estimator worse, not better. On HOSE the coarse tick grid relative to price levels makes this bind sooner than in US data. This is the assumption most likely to fail in practice, and we show what happens when it does.

(A3) No jumps, for the plain realized Amihud. \(J_{i,t} \equiv 0\) over day \(d\).

Reading. An earnings surprise or a limit-hit move is a large price change that is not the price impact of trading volume, but \(\text{RV}\) counts it as though it were. Given how routinely Vietnamese stocks hit the \(\pm7\%\) HOSE band (see the price-limit discussion in the market microstructure chapter), (A3) is not innocuous here. Relaxing it is exactly why we carry \(\text{BV}_{i,d}\) alongside.

(A4) Contemporaneous volume. \(\text{DVOL}_{i,d}\) is measured over the same window as the intraday returns.

Reading. If returns are computed over the continuous sessions but volume includes post-close put-through blocks, numerator and denominator refer to different trading. On HOSE the 14:45–15:00 negotiated window makes this a real bookkeeping issue rather than a technicality.

14.4.4 The Estimator

The realized Amihud replaces \(|R_{i,d}|\) with a scaled high-frequency estimate of the same day’s price variation:

\[ \text{RILLIQ}_{i,m} = \frac{1}{D_{i,m}}\sum_{d=1}^{D_{i,m}} \sqrt{\frac{2}{\pi}}\,\frac{\sqrt{\text{RV}_{i,d}}}{\text{DVOL}_{i,d}} \tag{14.14}\]

and its jump-robust counterpart replaces \(\text{RV}_{i,d}\) with \(\text{BV}_{i,d}\):

\[ \text{RILLIQ}^{\text{BV}}_{i,m} = \frac{1}{D_{i,m}}\sum_{d=1}^{D_{i,m}} \sqrt{\frac{2}{\pi}}\,\frac{\sqrt{\text{BV}_{i,d}}}{\text{DVOL}_{i,d}} \tag{14.15}\]

The constant \(\sqrt{2/\pi} \approx 0.7979\) is not cosmetic: it puts Equation 14.14 on exactly the same scale as Equation 38.2, so the two are numerically comparable and the realized version is a drop-in replacement everywhere the book currently uses \(\text{ILLIQ}\).

14.4.5 Derivation

Step 1: what \(|R_{i,d}|\) actually estimates. Condition on the volatility path and suppress the drift and jumps, so \(R_{i,d} \mid \text{IV}_{i,d} \sim N(0, \text{IV}_{i,d})\). For a half-normal variate,

\[ \mathbb{E}\!\left[|R_{i,d}| \,\middle|\, \text{IV}_{i,d}\right] = \sqrt{\tfrac{2}{\pi}}\sqrt{\text{IV}_{i,d}}, \qquad \mathbb{V}\!\left[|R_{i,d}| \,\middle|\, \text{IV}_{i,d}\right] = \left(1 - \tfrac{2}{\pi}\right)\text{IV}_{i,d} \tag{14.16}\]

So \(|R_{i,d}|\) is unbiased for \(\sqrt{2/\pi}\sqrt{\text{IV}_{i,d}}\) — Amihud’s numerator is targeting the right thing. Its relative standard deviation, however, is

\[ \frac{\text{sd}\!\left(|R_{i,d}|\right)}{\mathbb{E}\!\left[|R_{i,d}|\right]} = \sqrt{\frac{1 - 2/\pi}{2/\pi}} = \sqrt{\frac{\pi}{2} - 1} \approx 0.7555 \tag{14.17}\]

A single day’s Amihud numerator carries roughly 76% relative measurement error. This is a property of the half-normal distribution, not of any particular market — it is irreducible given one observation per day.

Step 2: what \(\sqrt{\text{RV}_{i,d}}\) estimates. Under (A1)–(A3), the realized variance obeys the central limit theorem of Barndorff-Nielsen and Shephard (2002),

\[ \sqrt{M}\left(\text{RV}_{i,d} - \text{IV}_{i,d}\right) \;\xrightarrow{d}\; N\!\left(0,\; 2\,\text{IQ}_{i,d}\right), \qquad \text{IQ}_{i,d} = \int_{d-1}^{d}\sigma_{i,s}^4\,ds \tag{14.18}\]

Applying the delta method to \(g(x) = \sqrt{x}\), with \(g'(\text{IV}) = 1/(2\sqrt{\text{IV}})\),

\[ \sqrt{M}\left(\sqrt{\text{RV}_{i,d}} - \sqrt{\text{IV}_{i,d}}\right) \;\xrightarrow{d}\; N\!\left(0,\; \frac{\text{IQ}_{i,d}}{2\,\text{IV}_{i,d}}\right) \tag{14.19}\]

Specializing to locally constant volatility over the day (\(\text{IV} = \sigma^2\), \(\text{IQ} = \sigma^4\)) gives the relative standard deviation

\[ \frac{\text{sd}\!\left(\sqrt{\text{RV}_{i,d}}\right)}{\mathbb{E}\!\left[\sqrt{\text{RV}_{i,d}}\right]} \approx \frac{1}{\sqrt{2M}} \tag{14.20}\]

Step 3: the comparison. Dividing Equation 14.17 by Equation 14.20, the realized numerator is more accurate by a factor of \(\sqrt{2M\left(\pi/2 - 1\right)}\). HOSE’s continuous sessions run 09:15–11:30 and 13:00–14:30, i.e. 225 minutes, so five-minute sampling gives \(M = 45\):

Table 14.2: Analytical measurement error of the Amihud numerator by sampling frequency, from Equation 14.17 and Equation 14.20. The 1-minute row is not a recommendation — see assumption (A2) and the simulation below.
Sampling \(M\) Relative SD of numerator Improvement vs. \(\lvert R_d\rvert\)
Daily close-to-close (\(\lvert R_d \rvert\)) 1 0.7555 —
15-minute 15 0.1826 4.1\(\times\)
5-minute 45 0.1054 7.2\(\times\)
1-minute 225 0.0471 16.0\(\times\)

At five-minute sampling the noise variance in the numerator falls by a factor of roughly 51.

Step 4: why this matters for the book’s results. Write the monthly sorting variable as truth plus error, \(\widehat{x}_{i,m} = x_{i,m} + \eta_{i,m}\). In the univariate liquidity sorts and the Fama-MacBeth regressions that follow later in this chapter, classical errors-in-variables attenuates the estimated slope by the reliability ratio

\[ \text{plim}\;\frac{\widehat{\gamma}}{\gamma} = \frac{\mathbb{V}[x]}{\mathbb{V}[x] + \mathbb{V}[\eta]} \tag{14.21}\]

Reducing \(\mathbb{V}[\eta]\) by ~51\(\times\) moves the reliability ratio toward one, which raises the measured liquidity premium and its \(t\)-statistic. The practical implication is uncomfortable and worth stating plainly: a liquidity premium estimated with the daily Amihud ratio is a lower bound. A weak or insignificant premium in Vietnamese data may reflect a noisy sorting variable rather than an absent premium.

14.4.6 Intuition

Amihud’s ratio asks “how far did the price move per VND traded?” and answers it with the single number the day happened to hand us — the open-to-close move. But a day in which the price wandered up 4%, back down 5%, and closed 1% lower is a day of substantial price sensitivity to trading, and \(|R_d| = 1\%\) records almost none of it. Realized volatility measures the path rather than the endpoints, so it captures the cumulative intraday price sensitivity that Amihud is trying to proxy for. Averaging \(M\) squared intraday returns instead of taking one absolute daily return is, statistically, the difference between one observation and \(M\) of them.

The catch is that the path is only observable through the microstructure. Push \(M\) high enough and you stop measuring the efficient price path and start measuring the bid-ask bounce. The whole art of the estimator is choosing \(M\) large enough to gain precision and small enough to keep (A2) intact.

14.4.7 Simulation Design

The following experiment benchmarks Equation 14.14 against Equation 38.2 on a common DGP, and then breaks (A2) and (A3) in turn to expose the failure modes. The target estimand is the scaled true daily illiquidity \(\sqrt{2/\pi}\sqrt{\text{IV}_{i,d}}/\text{DVOL}_{i,d}\), so bias and RMSE are measured against a known quantity.

Monte Carlo: realized vs. classic Amihud under noise and jumps
rng = np.random.default_rng(20260812)

M_HOSE = 45  # 225 continuous minutes on HOSE / 5-minute sampling


def simulate_days(n_days, M, noise_sd=0.0, jump_prob=0.0, jump_sd=0.0):
    """
    Simulate intraday paths with stochastic volatility, optional iid
    microstructure noise in observed log prices, and optional information
    jumps. Returns the ingredients of both illiquidity estimators.
    """
    # Integrated variance: gamma-distributed, mean daily vol about 2.5%
    IV = rng.gamma(shape=4.0, scale=(0.025 ** 2) / 4.0, size=n_days)

    # Efficient intraday returns
    eff = rng.normal(0.0, 1.0, size=(n_days, M)) * np.sqrt(IV / M)[:, None]

    # Information jumps (violates A3)
    if jump_prob > 0:
        hit = rng.binomial(1, jump_prob / M, size=(n_days, M))
        eff = eff + hit * rng.normal(0.0, jump_sd, size=(n_days, M))

    # Efficient log-price path, then contaminate with noise (violates A2)
    P = np.cumsum(eff, axis=1)
    P_obs = P + rng.normal(0.0, noise_sd, size=(n_days, M)) if noise_sd > 0 else P

    # Observed intraday returns and the daily open-to-close return
    r = np.diff(P_obs, axis=1, prepend=0.0)
    R_d = P_obs[:, -1]

    RV = np.sum(r ** 2, axis=1)
    BV = (np.pi / 2) * np.sum(np.abs(r[:, 1:]) * np.abs(r[:, :-1]), axis=1)

    # VND turnover, lognormal around 5 billion VND
    DVOL = np.exp(rng.normal(np.log(5e9), 1.0, size=n_days))

    return dict(IV=IV, R_d=R_d, RV=RV, BV=BV, DVOL=DVOL)


def score(sim, label):
    """Bias, RMSE (both relative to mean truth) and rank correlation."""
    k = np.sqrt(2.0 / np.pi)
    truth = k * np.sqrt(sim['IV']) / sim['DVOL']

    estimators = {
        'Amihud |R|/DVOL': np.abs(sim['R_d']) / sim['DVOL'],
        'Realized Amihud': k * np.sqrt(sim['RV']) / sim['DVOL'],
        'Bipower Amihud': k * np.sqrt(np.maximum(sim['BV'], 0)) / sim['DVOL'],
    }

    rows = []
    for name, est in estimators.items():
        rows.append({
            'estimator': name,
            'rel_bias': (est - truth).mean() / truth.mean(),
            'rel_rmse': np.sqrt(((est - truth) ** 2).mean()) / truth.mean(),
            'rank_corr': stats.spearmanr(est, truth).statistic,
        })

    out = pd.DataFrame(rows).set_index('estimator')
    print(f"\n--- {label}")
    print(out.round(4))
    return out


# Baseline: assumptions hold
score(simulate_days(30_000, M_HOSE), "Baseline (A1-A4 hold), M=45")

# Failure case A: microstructure noise, and what oversampling does to it
score(simulate_days(30_000, M_HOSE, noise_sd=0.0015),
      "Noise sd=0.15%, 5-minute sampling (M=45)")
score(simulate_days(30_000, 225, noise_sd=0.0015),
      "Noise sd=0.15%, 1-minute sampling (M=225) -- oversampled")

# Failure case B: information jumps
score(simulate_days(30_000, M_HOSE, jump_prob=0.2, jump_sd=0.03),
      "Information jumps (~1 per 5 days, 3% size)")

--- Baseline (A1-A4 hold), M=45
                 rel_bias  rel_rmse  rank_corr
estimator                                     
Amihud |R|/DVOL    0.0057    1.2755     0.7043
Realized Amihud   -0.0046    0.1772     0.9941
Bipower Amihud    -0.0181    0.1978     0.9924

--- Noise sd=0.15%, 5-minute sampling (M=45)
                 rel_bias  rel_rmse  rank_corr
estimator                                     
Amihud |R|/DVOL    0.0025    1.3034     0.7019
Realized Amihud    0.1588    0.3329     0.9901
Bipower Amihud     0.1501    0.3493     0.9875

--- Noise sd=0.15%, 1-minute sampling (M=225) -- oversampled
                 rel_bias  rel_rmse  rank_corr
estimator                                     
Amihud |R|/DVOL    0.0070    1.3661     0.7107
Realized Amihud    0.6584    1.1145     0.9827
Bipower Amihud     0.6969    1.1837     0.9807

--- Information jumps (~1 per 5 days, 3% size)
                 rel_bias  rel_rmse  rank_corr
estimator                                     
Amihud |R|/DVOL    0.1190    1.4930     0.6929
Realized Amihud    0.0933    0.5300     0.9709
Bipower Amihud     0.0122    0.2429     0.9891
rel_bias rel_rmse rank_corr
estimator
Amihud |R|/DVOL 0.118971 1.492983 0.692885
Realized Amihud 0.093316 0.529996 0.970871
Bipower Amihud 0.012169 0.242949 0.989143

Three results emerge, and each has a direct counterpart in the analytics above.

First, under (A1)–(A4) both estimators are essentially unbiased — the daily Amihud numerator is, as Equation 14.16 says, unbiased for the right target — but their precision is not remotely comparable. Relative RMSE falls from about 1.28 to about 0.18, a ratio of roughly 7.2, which is exactly the improvement factor Table 14.2 predicts analytically for \(M = 45\). The rank correlation with true illiquidity rises from about 0.70 to about 0.99. That rank correlation is the number that matters most for this book, because it governs whether a portfolio sort puts a stock in the right quintile: at 0.70, a substantial fraction of stocks are misclassified by the sorting variable alone.

Second, breaking (A2) biases the realized Amihud upward, and sampling at one minute instead of five makes the bias roughly four times worse rather than better — about +16% at \(M = 45\) against about +66% at \(M = 225\). This is the non-obvious lesson of the whole section: with noise present, more data sampled more finely is actively harmful. Note also that the plain daily Amihud is essentially immune to this particular problem, since a single open-to-close return contains only one dose of noise. Robustness to microstructure noise is the one dimension on which the classic measure wins.

Third, under jumps the plain realized Amihud inherits a clear upward bias (about +9%) while the bipower version in Equation 14.15 stays close to unbiased (about +1%) and also delivers the lowest RMSE of the three. Given how routinely Vietnamese stocks hit their price bands, this is the configuration to prefer locally.

14.4.8 Replication on Vietnamese Data

Lacava, Ranaldo, and Santucci de Magistris (2026) use US high-frequency data, so a direct replication is not possible with the daily panel assembled earlier in this chapter. The honest position for this book is that the realized Amihud is currently out of reach for most of the HOSE cross-section, for a reason worth being explicit about: the estimator’s precision is driven by \(M\), the number of non-trivial intraday returns. A stock that trades a handful of times per session does not have 45 informative five-minute returns — it has a five-minute grid mostly filled with forward-filled stale prices, whose mechanical zero returns contribute nothing to \(\text{RV}\). The market microstructure chapter simulates this directly (?sec-micro-realized-illiq) and reaches a sharper conclusion than one might guess. The precision gain is governed by the number of intervals in which the price actually changed rather than the number of grid points, so it decays from the theoretical 7.2\(\times\) to under 2\(\times\) for a stock trading five times a day. The accompanying downward bias in \(\text{RV}\) is real but second-order by comparison, because coarsening the sampling of a martingale does not bias realized variance in expectation; what staleness removes is the movement before the first trade and after the last. This is the same thin-trading problem that motivates the zero-return measure in Equation 14.4, and it bites the realized Amihud harder than it bites the daily version.

The practical consequence is that this estimator is a refinement for the liquid end of the market — roughly the VN30 constituents and the larger mid-caps — and not a replacement for Equation 38.2 across the full panel. Constructing it for Vietnam requires intraday tick data from HOSE, which is not part of the daily pipeline used elsewhere in this chapter.

14.4.9 Practical Guidance

  1. Sample at five minutes, not finer. For HOSE’s 225 continuous minutes this gives \(M = 45\) and a 7.2\(\times\) precision gain. The temptation to go to one minute should be resisted: Table 14.2 shows the theoretical gain, but (A2) fails long before you collect it. Five minutes is not an arbitrary convention — it is the frequency at which the bias-variance trade-off between sampling error and noise accumulation is conventionally struck (Andersen et al. 2003), the optimal-sampling calculations of Aït-Sahalia, Mykland, and Zhang (2005) and Bandi and Russell (2008) land in the same neighbourhood for liquid US equities, and Liu, Patton, and Sheppard (2015) find across a wide range of asset classes that very few realized measures reliably beat five-minute \(\text{RV}\). Since HOSE is thinner and its tick grid coarser than the markets those studies examine, the optimal interval there is if anything longer than five minutes, never shorter.
  2. Screen on trading frequency before computing it. Require a minimum number of intervals containing an actual trade — not merely a price — and fall back to Equation 38.2 below that threshold. Report which stocks fall back.
  3. Handle the lunch break and the auctions explicitly. The 11:30–13:00 gap is not a return interval, and the ATO and ATC prices come from batch auctions rather than continuous matching. Compute \(\text{RV}\) within each continuous session and sum, rather than differencing across the break or across the auctions.
  4. Exclude put-through trades from the denominator, per (A4), so that the 14:45–15:00 negotiated block window does not inflate \(\text{DVOL}\) against a numerator measured over continuous trading only.
  5. Prefer the bipower version on limit-hit days. Vietnam’s daily price bands make large discrete moves common, and Equation 14.15 is the version that survives them.
  6. Escalate to a noise-robust estimator only if you must sample finely. The estimators in Equation 14.13 are the right default at five minutes. If the application genuinely requires a finer grid, the literature offers three families, in rough order of implementation cost: the two-scales estimator of Zhang, Mykland, and Aït-Sahalia (2005), which subsamples at two frequencies and extrapolates the noise away; the pre-averaging approach of Jacod et al. (2009), which averages returns within blocks before squaring; and the realized kernels of Barndorff-Nielsen et al. (2008), which weight autocovariances of intraday returns. Hansen and Lunde (2006) is the standard diagnostic reference for detecting how much noise is present in the first place, and their signature plot — \(\text{RV}\) against sampling frequency — should be produced for the Vietnamese cross-section before any of this is attempted.
  7. Consider nearest-neighbour truncation as a bipower alternative. Andersen, Dobrev, and Schaumburg (2012) propose the MinRV and MedRV estimators, which are jump-robust like \(\text{BV}\) but better behaved when jumps arrive in adjacent intervals — a realistic scenario on a limit-hit day, when the band is approached over several consecutive intervals rather than in one move.
  8. Software. There is no single package that does this end to end; \(\text{RV}\) and \(\text{BV}\) are a few lines of pandas, as in the simulation above. The realized routines in the arch ecosystem cover the noise-robust estimators for readers who prefer not to implement them.

14.4.10 Pitfalls

  • Oversampling. The single most common error. More observations look like more information, but Equation 14.18 assumes the efficient price is what is being sampled. Past a few minutes on HOSE it is not.
  • Treating zero intraday returns as information. On a five-minute grid, a stale price produces a zero return that \(\text{RV}\) reads as zero volatility. Left unscreened, this makes the least liquid stocks look the most liquid — the sign of the error is the opposite of what one would guess.
  • Mixing the scale. Dropping the \(\sqrt{2/\pi}\) constant makes Equation 14.14 roughly 25% larger than Equation 38.2, which silently breaks any comparison to previously published Amihud levels or to the sorts elsewhere in this chapter.
  • Assuming the gain transfers to the premium one-for-one. Equation 14.21 says a cleaner sorting variable raises the measured premium toward the true one. It does not create a premium that is not there, and it does not fix the survivorship and delisting issues that govern which firm-months enter the panel in the first place.
  • Forgetting that the denominator is unchanged. All of the above improves the numerator only. Volume remains subject to the reporting conventions and put-through complications discussed in the microstructure chapter.

14.5 Assembling the Liquidity Panel

We merge all seven measures into a single firm-month panel for comparative analysis.

Merge all liquidity measures into a unified panel
# Start with monthly returns as the base
panel = monthly[['ticker', 'month_end', 'monthly_return',
                  'market_cap']].copy()

# Merge each liquidity measure
for name, df, key_col in [
    ('Amihud', amihud_monthly, 'amihud'),
    ('Zero Return', zero_monthly, 'zero_return_pct'),
    ('Turnover', turnover_monthly, 'log_turnover'),
    ('Roll', roll_monthly, 'roll_spread'),
    ('Corwin-Schultz', cs_monthly, 'cs_spread'),
    ('Quoted Spread', quoted_monthly, 'quoted_spread'),
    ('Kyle Lambda', kyle_monthly, 'log_kyle'),
]:
    panel = panel.merge(
        df[['ticker', 'month_end', key_col]],
        on=['ticker', 'month_end'],
        how='left'
    )

# Add log market cap
panel['log_mcap'] = np.log(panel['market_cap'].clip(lower=1))

# Add fundamentals (lagged)
fund_lagged = fundamentals.copy()
fund_lagged['merge_year'] = fund_lagged['fiscal_year'] + 1
panel = panel.merge(
    fund_lagged[['ticker', 'merge_year', 'book_equity']].rename(
        columns={'merge_year': 'year'}),
    left_on=['ticker', panel['month_end'].dt.year],
    right_on=['ticker', 'year'],
    how='left'
)
panel['bm'] = panel['book_equity'] / panel['market_cap']

print(f"Unified panel: {len(panel):,} firm-months")
print(f"\nCoverage by measure:")
liquidity_cols = ['amihud', 'zero_return_pct', 'log_turnover',
                   'roll_spread', 'cs_spread', 'quoted_spread', 'log_kyle']
for col in liquidity_cols:
    pct = panel[col].notna().mean()
    print(f"  {col:<20}: {pct:.1%}")

14.6 Cross-Sectional Properties of Liquidity

14.6.1 Summary Statistics by Size Quintile

Liquidity varies enormously across the size distribution. Small-cap Vietnamese stocks can be orders of magnitude less liquid than large-caps.

Compute liquidity statistics by market cap quintile
# Assign size quintiles within each month
panel['size_quintile'] = (
    panel.groupby('month_end')['market_cap']
    .transform(lambda x: pd.qcut(x, 5, labels=['Q1 (Small)', 'Q2',
                                                  'Q3', 'Q4',
                                                  'Q5 (Large)'],
                                   duplicates='drop'))
)

# Average liquidity by quintile
liq_by_size = (
    panel.groupby('size_quintile')[liquidity_cols]
    .mean()
    .round(4)
)

print("Average Liquidity by Market Cap Quintile:")
print(liq_by_size.to_string())
Bar charts of liquidity measures by size quintile
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
axes = axes.flatten()

measures_to_plot = [
    ('amihud', 'Amihud (log)', '#2C5F8A'),
    ('zero_return_pct', 'Zero-Return %', '#C0392B'),
    ('log_turnover', 'Log Turnover', '#27AE60'),
    ('roll_spread', 'Roll Spread', '#E67E22'),
    ('cs_spread', 'Corwin-Schultz Spread', '#8E44AD'),
    ('quoted_spread', 'Quoted Spread', '#1ABC9C')
]

for i, (col, label, color) in enumerate(measures_to_plot):
    data = panel.groupby('size_quintile')[col].mean()
    axes[i].bar(range(len(data)), data.values,
                color=color, alpha=0.85, edgecolor='white')
    axes[i].set_xticks(range(len(data)))
    axes[i].set_xticklabels(data.index, fontsize=8)
    axes[i].set_ylabel(label)
    axes[i].set_title(label)

plt.suptitle('Liquidity Measures by Market Cap Quintile', fontsize=14)
plt.tight_layout()
plt.show()
Figure 14.1

14.6.2 Correlation Structure

How strongly do the different liquidity measures correlate? If they capture the same underlying dimension, we expect high correlations. If they capture different dimensions (tightness vs. depth vs. activity), correlations will be moderate.

Compute and plot cross-sectional rank correlations
# Rank correlations (Spearman) among liquidity measures
# Reverse turnover sign so higher = less liquid (consistent direction)
panel_corr = panel[liquidity_cols].copy()
panel_corr['neg_log_turnover'] = -panel_corr['log_turnover']
corr_cols = ['amihud', 'zero_return_pct', 'neg_log_turnover',
              'roll_spread', 'cs_spread', 'quoted_spread', 'log_kyle']
corr_labels = ['Amihud', 'Zero-Return', 'Neg. Turnover', 'Roll',
                'Corwin-Schultz', 'Quoted Spread', 'Kyle λ']

rank_corr = panel_corr[corr_cols].corr(method='spearman')
rank_corr.index = corr_labels
rank_corr.columns = corr_labels

fig, ax = plt.subplots(figsize=(9, 8))
mask = np.triu(np.ones_like(rank_corr, dtype=bool), k=1)
sns.heatmap(
    rank_corr, mask=mask, annot=True, fmt='.2f',
    cmap='YlOrRd', vmin=0, vmax=1, square=True,
    linewidths=0.5, ax=ax,
    cbar_kws={'label': 'Spearman Rank Correlation'}
)
ax.set_title('Cross-Sectional Rank Correlations Among Liquidity Measures')
plt.tight_layout()
plt.show()
Figure 14.2

14.6.3 Principal Component Analysis of Liquidity

Given the multidimensionality of liquidity, we extract a composite liquidity factor using PCA:

Extract principal components from standardized liquidity measures
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA

# Standardize each measure within each month (cross-sectional)
liq_data = panel[liquidity_cols].copy()
liq_data['neg_log_turnover'] = -liq_data['log_turnover']

pca_cols = ['amihud', 'zero_return_pct', 'neg_log_turnover',
             'roll_spread', 'cs_spread', 'log_kyle']

# Drop rows with any missing liquidity measure
liq_complete = panel.dropna(subset=pca_cols).copy()

# Cross-sectional standardization by month
def standardize_within_month(df, cols):
    for col in cols:
        df[col + '_z'] = (
            df.groupby('month_end')[col]
            .transform(lambda x: (x - x.mean()) / x.std())
        )
    return df

liq_complete = standardize_within_month(liq_complete, pca_cols)
z_cols = [c + '_z' for c in pca_cols]

# Pool all months for PCA
pca_input = liq_complete[z_cols].dropna()
pca = PCA(n_components=3)
pca.fit(pca_input)

print("PCA Explained Variance Ratios:")
for i, (var, cumvar) in enumerate(zip(
    pca.explained_variance_ratio_,
    np.cumsum(pca.explained_variance_ratio_)
)):
    print(f"  PC{i+1}: {var:.3f} (cumulative: {cumvar:.3f})")

print("\nPC1 Loadings:")
for col, loading in zip(pca_cols, pca.components_[0]):
    print(f"  {col:<20}: {loading:.3f}")

# Assign PC1 as composite illiquidity
liq_complete['illiq_pc1'] = pca.transform(
    liq_complete[z_cols].values
)[:, 0]

14.7 Aggregate Liquidity and Market Conditions

14.7.1 Time Series of Market Liquidity

Aggregate liquidity (i.e., the average illiquidity across all stocks) varies substantially over time. Chordia, Roll, and Subrahmanyam (2001) document that market-wide liquidity declines during periods of high volatility and negative market returns.

Plot aggregate liquidity time series
# Compute monthly cross-sectional aggregates
agg_liquidity = (
    panel.groupby('month_end')
    .agg(
        amihud_median=('amihud', 'median'),
        zero_ret_median=('zero_return_pct', 'median'),
        turnover_median=('log_turnover', 'median'),
        roll_median=('roll_spread', 'median'),
        cs_median=('cs_spread', 'median'),
        n_stocks=('ticker', 'nunique')
    )
    .reset_index()
)

# Standardize for plotting
for col in ['amihud_median', 'zero_ret_median', 'roll_median']:
    agg_liquidity[col + '_z'] = (
        (agg_liquidity[col] - agg_liquidity[col].mean())
        / agg_liquidity[col].std()
    )

fig, axes = plt.subplots(2, 1, figsize=(14, 9), height_ratios=[2, 1])

# Panel A: Aggregate illiquidity
axes[0].plot(agg_liquidity['month_end'], agg_liquidity['amihud_median_z'],
             color='#2C5F8A', linewidth=1.5, label='Amihud')
axes[0].plot(agg_liquidity['month_end'], agg_liquidity['zero_ret_median_z'],
             color='#C0392B', linewidth=1.5, label='Zero-Return')
axes[0].plot(agg_liquidity['month_end'], agg_liquidity['roll_median_z'],
             color='#27AE60', linewidth=1.5, label='Roll Spread')
axes[0].axhline(y=0, color='gray', linewidth=0.5)
axes[0].set_ylabel('Standardized Illiquidity')
axes[0].set_title('Panel A: Aggregate Illiquidity Over Time')
axes[0].legend(fontsize=9)

# Shade crisis periods
crisis_periods = [
    ('2008-06-01', '2009-03-31', 'GFC'),
    ('2011-01-01', '2011-12-31', 'Tightening'),
    ('2020-02-01', '2020-05-31', 'COVID')
]
for start, end, label in crisis_periods:
    axes[0].axvspan(pd.Timestamp(start), pd.Timestamp(end),
                     alpha=0.15, color='gray')
    mid = pd.Timestamp(start) + (pd.Timestamp(end) - pd.Timestamp(start)) / 2
    axes[0].text(mid, axes[0].get_ylim()[1] * 0.9, label,
                 ha='center', fontsize=8, color='gray')

# Panel B: Market return
market_monthly = factors[['month_end', 'mkt_excess']].copy()
market_monthly['month_end'] = pd.to_datetime(market_monthly['month_end'])
axes[1].bar(market_monthly['month_end'],
            market_monthly['mkt_excess'] * 100,
            width=25,
            color=['#27AE60' if r > 0 else '#C0392B'
                   for r in market_monthly['mkt_excess']],
            alpha=0.6)
axes[1].set_ylabel('Market Excess Return (%)')
axes[1].set_xlabel('Date')
axes[1].set_title('Panel B: VN-Index Monthly Excess Return')

plt.tight_layout()
plt.show()
Figure 14.3

14.7.2 Commonality in Liquidity

Chordia, Roll, and Subrahmanyam (2000) find that individual stock liquidity co-moves with market liquidity, even after controlling for firm-specific factors. We test for commonality in Vietnam by regressing changes in firm-level liquidity on changes in market-level liquidity:

\[ \Delta L_{i,m} = \alpha_i + \beta_i \Delta L_{M,m} + \gamma_i \Delta L_{M,m-1} + \delta_i \Delta L_{M,m+1} + \varepsilon_{i,m} \tag{14.22}\]

where \(\Delta L_{i,m}\) is the change in firm \(i\)’s illiquidity, \(\Delta L_{M,m}\) is the change in market-average illiquidity (excluding firm \(i\)), and the lead/lag terms capture non-synchronous adjustment. The coefficient \(\beta_i\) measures the sensitivity of firm \(i\)’s liquidity to market-wide liquidity shocks.

Test for commonality in liquidity across Vietnamese stocks
# Compute monthly changes in Amihud for each firm and the market
panel_common = panel[['ticker', 'month_end', 'amihud']].dropna().copy()
panel_common = panel_common.sort_values(['ticker', 'month_end'])
panel_common['d_amihud'] = (
    panel_common.groupby('ticker')['amihud'].diff()
)

# Market-level illiquidity change (equal-weighted, excluding firm i)
mkt_liq = (
    panel_common.groupby('month_end')['amihud']
    .mean()
    .diff()
    .to_frame('d_amihud_mkt')
)
mkt_liq['d_amihud_mkt_lag'] = mkt_liq['d_amihud_mkt'].shift(1)
mkt_liq['d_amihud_mkt_lead'] = mkt_liq['d_amihud_mkt'].shift(-1)

panel_common = panel_common.merge(mkt_liq, on='month_end', how='left')

# Estimate commonality for each firm
def estimate_commonality(group, min_obs=24):
    g = group.dropna(subset=['d_amihud', 'd_amihud_mkt'])
    if len(g) < min_obs:
        return None
    y = g['d_amihud']
    X = sm.add_constant(g[['d_amihud_mkt', 'd_amihud_mkt_lag',
                            'd_amihud_mkt_lead']])
    try:
        model = sm.OLS(y, X).fit()
        return pd.Series({
            'beta_mkt': model.params['d_amihud_mkt'],
            'beta_t': model.tvalues['d_amihud_mkt'],
            'r_squared': model.rsquared
        })
    except Exception:
        return None

commonality = (
    panel_common
    .groupby('ticker')
    .apply(estimate_commonality)
    .dropna()
)

print("Commonality in Liquidity (Amihud):")
print(f"  Mean beta_mkt: {commonality['beta_mkt'].mean():.3f}")
print(f"  Median beta_mkt: {commonality['beta_mkt'].median():.3f}")
print(f"  % significant at 5%: "
      f"{(commonality['beta_t'].abs() > 1.96).mean():.1%}")
print(f"  Mean R-squared: {commonality['r_squared'].mean():.3f}")

14.8 Is Liquidity Priced?

14.8.1 Portfolio Sorts

We test whether illiquidity predicts future returns by sorting stocks into quintile portfolios based on lagged liquidity measures and comparing average returns across quintiles.

Sort stocks into quintiles by lagged illiquidity and compute returns
def liquidity_portfolio_sort(panel_df, liq_col, n_groups=5):
    """
    Compute quintile portfolio returns sorted on lagged liquidity.
    Lag the sorting variable by one month to avoid look-ahead bias.
    """
    df = panel_df[['ticker', 'month_end', 'monthly_return',
                    'market_cap', liq_col]].dropna().copy()
    df = df.sort_values(['ticker', 'month_end'])
    
    # Lag the sorting variable
    df['liq_lag'] = df.groupby('ticker')[liq_col].shift(1)
    df = df.dropna(subset=['liq_lag', 'monthly_return'])
    
    # Assign quintiles within each month
    df['quintile'] = (
        df.groupby('month_end')['liq_lag']
        .transform(lambda x: pd.qcut(x, n_groups, labels=False,
                                       duplicates='drop'))
    )
    
    # EW portfolio returns by quintile-month
    port_returns = (
        df.groupby(['month_end', 'quintile'])['monthly_return']
        .mean()
        .unstack()
    )
    
    # Long-short (Q5 - Q1)
    if n_groups - 1 in port_returns.columns and 0 in port_returns.columns:
        port_returns['long_short'] = (
            port_returns[n_groups - 1] - port_returns[0]
        )
    
    return port_returns

# Run sorts for each illiquidity measure
sort_measures = {
    'Amihud': 'amihud',
    'Zero-Return': 'zero_return_pct',
    'Neg. Turnover': 'log_turnover',  # Will reverse below
    'Roll Spread': 'roll_spread',
    'Corwin-Schultz': 'cs_spread',
}

# For turnover, negate so higher = less liquid
panel_sorts = panel.copy()
panel_sorts['neg_turnover'] = -panel_sorts['log_turnover']
sort_measures_actual = {
    'Amihud': 'amihud',
    'Zero-Return': 'zero_return_pct',
    'Neg. Turnover': 'neg_turnover',
    'Roll Spread': 'roll_spread',
    'Corwin-Schultz': 'cs_spread',
}

print("Liquidity Premium (EW, Quintile Sorts):")
print(f"{'Measure':<18} {'Q1 (Liquid)':>12} {'Q5 (Illiquid)':>14} "
      f"{'Q5-Q1':>10} {'t-stat':>8}")
print("-" * 62)

sort_results = {}
for name, col in sort_measures_actual.items():
    ports = liquidity_portfolio_sort(panel_sorts, col)
    sort_results[name] = ports
    
    q1 = ports[0].mean() * 12
    q5 = ports[4].mean() * 12 if 4 in ports.columns else np.nan
    ls = ports['long_short'].mean() * 12 if 'long_short' in ports else np.nan
    ls_se = ports['long_short'].std() / np.sqrt(len(ports)) * np.sqrt(12) if 'long_short' in ports else np.nan
    t = ls / ls_se if ls_se and ls_se > 0 else np.nan
    
    print(f"{name:<18} {q1:>12.4f} {q5:>14.4f} {ls:>10.4f} {t:>8.2f}")
Bar chart of quintile returns by liquidity measure
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
axes = axes.flatten()

colors_quintile = ['#27AE60', '#2ECC71', '#F1C40F', '#E67E22', '#C0392B']

for i, (name, ports) in enumerate(sort_results.items()):
    if i >= 5:
        break
    quintile_means = [ports[q].mean() * 12 * 100 for q in range(5)
                       if q in ports.columns]
    axes[i].bar(range(len(quintile_means)), quintile_means,
                color=colors_quintile[:len(quintile_means)],
                alpha=0.85, edgecolor='white')
    axes[i].set_xticks(range(len(quintile_means)))
    axes[i].set_xticklabels([f'Q{q+1}' for q in range(len(quintile_means))])
    axes[i].set_ylabel('Annualized Return (%)')
    axes[i].set_title(name)
    axes[i].axhline(y=0, color='gray', linewidth=0.5)

# Hide unused subplot
if len(sort_results) < 6:
    axes[5].set_visible(False)

plt.suptitle('Average Returns by Illiquidity Quintile', fontsize=14)
plt.tight_layout()
plt.show()
Figure 14.4

14.8.2 Fama-MacBeth Cross-Sectional Regressions

Portfolio sorts are informative but cannot control for multiple characteristics simultaneously. We use Fama and French (1993) -style cross-sectional regressions to test whether liquidity predicts returns after controlling for size, value, and momentum:

\[ R_{i,m+1} = \gamma_{0,m} + \gamma_{1,m} \text{ILLIQ}_{i,m} + \gamma_{2,m} \ln(\text{MCap}_{i,m}) + \gamma_{3,m} \text{BM}_{i,m} + \gamma_{4,m} R_{i,m-12:m-1} + \varepsilon_{i,m+1} \tag{14.23}\]

The time-series average of the monthly coefficient \(\bar{\gamma}_1\) estimates the illiquidity premium, and its t-statistic uses the Fama and French (1993) standard error.

Fama-MacBeth regressions of returns on lagged liquidity and controls
def fama_macbeth(panel_df, illiq_col, controls=['log_mcap', 'bm'],
                  min_stocks=50):
    """
    Run Fama-MacBeth cross-sectional regressions of
    next-month returns on lagged illiquidity and controls.
    """
    df = panel_df.copy()
    df = df.sort_values(['ticker', 'month_end'])
    
    # Lag the illiquidity measure
    df['illiq_lag'] = df.groupby('ticker')[illiq_col].shift(1)
    
    # Lag controls
    for c in controls:
        df[c + '_lag'] = df.groupby('ticker')[c].shift(1)
    
    regressors = ['illiq_lag'] + [c + '_lag' for c in controls]
    df = df.dropna(subset=['monthly_return'] + regressors)
    
    # Month-by-month cross-sectional regressions
    months = sorted(df['month_end'].unique())
    gamma_list = []
    
    for month in months:
        cross = df[df['month_end'] == month]
        if len(cross) < min_stocks:
            continue
        
        y = cross['monthly_return'].values
        X = sm.add_constant(cross[regressors].values)
        
        try:
            model = sm.OLS(y, X).fit()
            gammas = {'month_end': month, 'intercept': model.params[0]}
            for j, reg in enumerate(regressors):
                gammas[reg] = model.params[j + 1]
            gamma_list.append(gammas)
        except Exception:
            pass
    
    gamma_df = pd.DataFrame(gamma_list)
    
    # Time-series averages and t-statistics
    results = {}
    for col in ['intercept'] + regressors:
        mean = gamma_df[col].mean()
        se = gamma_df[col].std() / np.sqrt(len(gamma_df))
        t = mean / se if se > 0 else np.nan
        results[col] = {'Coefficient': mean, 'SE': se, 't-stat': t}
    
    return pd.DataFrame(results).T, gamma_df

# Run for each illiquidity measure
print("Fama-MacBeth Regressions: R_{i,m+1} on ILLIQ_{i,m} + controls")
print("=" * 70)

for name, col in [('Amihud', 'amihud'),
                    ('Zero-Return', 'zero_return_pct'),
                    ('Roll Spread', 'roll_spread'),
                    ('Corwin-Schultz', 'cs_spread')]:
    results, gammas = fama_macbeth(panel, col)
    print(f"\n{name}:")
    print(results[['Coefficient', 't-stat']].round(4).to_string())

14.8.3 Factor-Adjusted Liquidity Premium

The liquidity premium may be partially or fully explained by existing risk factors (size, value, momentum). We test this by regressing the long-short liquidity portfolio returns on the Fama-French-Carhart factors:

Regress liquidity long-short returns on FF+Carhart factors
print("Factor-Adjusted Liquidity Premium:")
print(f"{'Measure':<18} {'Alpha (ann.)':>12} {'Alpha t':>10} "
      f"{'MKT':>8} {'SMB':>8} {'HML':>8} {'R2':>6}")
print("-" * 72)

for name, ports in sort_results.items():
    if 'long_short' not in ports.columns:
        continue
    
    ls_series = ports['long_short'].to_frame('ls')
    ls_series.index = pd.to_datetime(ls_series.index)
    
    merged = ls_series.merge(factors, left_index=True,
                              right_on='month_end', how='inner')
    
    if len(merged) < 24:
        continue
    
    y = merged['ls']
    X = sm.add_constant(merged[['mkt_excess', 'smb', 'hml', 'wml']])
    
    model = sm.OLS(y, X).fit(cov_type='HAC', cov_kwds={'maxlags': 6})
    
    alpha_ann = model.params['const'] * 12
    alpha_t = model.tvalues['const']
    mkt_b = model.params['mkt_excess']
    smb_b = model.params['smb']
    hml_b = model.params['hml']
    r2 = model.rsquared
    
    print(f"{name:<18} {alpha_ann:>12.4f} {alpha_t:>10.2f} "
          f"{mkt_b:>8.3f} {smb_b:>8.3f} {hml_b:>8.3f} {r2:>6.3f}")

14.9 Liquidity and Transaction Cost Estimation

14.9.1 Translating Measures to Trading Costs

For practitioners, the key question is: what does a given Amihud or spread value mean in terms of actual VND cost per trade? We calibrate the relationship between our low-frequency proxies and explicit trading costs.

Calibrate liquidity proxies to estimated round-trip trading costs
def estimate_round_trip_cost(row):
    """
    Estimate total round-trip trading cost (in %) from
    multiple liquidity proxies.
    
    Components:
    1. Explicit: commission + tax (~0.35% round-trip)
    2. Spread cost: half-spread each way
    3. Price impact: function of trade size
    """
    explicit = 0.0035  # 35 bps round-trip
    
    # Use Corwin-Schultz or quoted spread as spread estimate
    spread = row.get('cs_spread', row.get('quoted_spread', 0.005))
    spread_cost = spread  # Full spread = round-trip cost
    
    # Price impact (approximate from Amihud)
    # For a trade of 1% of daily volume
    amihud_raw = row.get('amihud_raw', 0)
    impact = amihud_raw * 0.01  # Rough approximation
    
    return explicit + spread_cost + impact

panel['estimated_rtc'] = panel.apply(estimate_round_trip_cost, axis=1)

# Distribution by size quintile
rtc_by_size = (
    panel.groupby('size_quintile')['estimated_rtc']
    .agg(['mean', 'median', 'std'])
    .round(4)
)
print("Estimated Round-Trip Cost by Size Quintile (%):")
print((rtc_by_size * 100).round(2).to_string())
Plot estimated trading costs by size quintile
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Panel A: Distribution
for q, color in zip(['Q1 (Small)', 'Q3', 'Q5 (Large)'],
                     ['#C0392B', '#F1C40F', '#27AE60']):
    subset = panel[panel['size_quintile'] == q]['estimated_rtc'].dropna()
    subset = subset[subset < 0.15]  # Trim extreme
    axes[0].hist(subset * 100, bins=50, density=True, alpha=0.5,
                  color=color, label=q, edgecolor='white')
axes[0].set_xlabel('Estimated Round-Trip Cost (%)')
axes[0].set_ylabel('Density')
axes[0].set_title('Panel A: Cost Distribution by Size')
axes[0].legend()

# Panel B: Time series of median cost
cost_ts = (
    panel.groupby('month_end')['estimated_rtc']
    .median()
    .reset_index()
)
axes[1].plot(pd.to_datetime(cost_ts['month_end']),
             cost_ts['estimated_rtc'] * 100,
             color='#2C5F8A', linewidth=1.5)
axes[1].set_xlabel('Date')
axes[1].set_ylabel('Median Round-Trip Cost (%)')
axes[1].set_title('Panel B: Aggregate Trading Costs Over Time')

plt.tight_layout()
plt.show()
Figure 14.5

14.9.2 Strategy Implementability

A critical application of liquidity measurement is testing whether a given anomaly strategy remains profitable after accounting for realistic trading costs. We compute net-of-cost returns for the liquidity-sorted portfolios themselves. This is an inherently conservative test because the illiquid long leg carries the highest costs.

Compute net-of-cost returns for illiquidity-sorted portfolios
# For each quintile, estimate average monthly turnover and cost
# and subtract from gross returns
for name, ports in sort_results.items():
    if 'long_short' not in ports.columns:
        continue
    
    gross_ann = ports['long_short'].mean() * 12
    
    # Estimate costs: illiquid quintile has higher costs
    # Assume monthly turnover of ~15% for long-short with monthly rebalancing
    turnover = 0.15
    cost_q1 = 0.003  # 30 bps per trade for liquid stocks
    cost_q5 = 0.015  # 150 bps for illiquid stocks
    avg_cost = (cost_q1 + cost_q5) / 2  # Average across long and short
    monthly_tc = turnover * avg_cost
    
    net_ann = gross_ann - monthly_tc * 12
    
    print(f"{name:<18}: Gross = {gross_ann*100:>6.2f}%, "
          f"TC = {monthly_tc*1200:>6.1f} bps/mo, "
          f"Net = {net_ann*100:>6.2f}%")

14.10 Liquidity During Market Stress

14.10.1 Flight to Liquidity

During market stress, investors sell illiquid assets and buy liquid ones, which is a “flight to liquidity” that widens the return differential between liquid and illiquid stocks. Hameed, Kang, and Viswanathan (2010) show that this pattern is strongest when market returns are most negative.

Test for flight-to-liquidity during market stress
# Merge Amihud-sorted portfolio returns with market returns
amihud_ports = sort_results.get('Amihud')
if amihud_ports is not None and 'long_short' in amihud_ports.columns:
    ftl_data = pd.merge(
        amihud_ports['long_short'].to_frame('illiq_premium'),
        factors[['month_end', 'mkt_excess']].set_index('month_end'),
        left_index=True, right_index=True, how='inner'
    )
    
    # Classify market states
    ftl_data['mkt_state'] = pd.cut(
        ftl_data['mkt_excess'],
        bins=[-np.inf,
              ftl_data['mkt_excess'].quantile(0.20),
              ftl_data['mkt_excess'].quantile(0.80),
              np.inf],
        labels=['Bear (bottom 20%)', 'Normal', 'Bull (top 20%)']
    )
    
    # Illiquidity premium by market state
    state_premium = (
        ftl_data.groupby('mkt_state')['illiq_premium']
        .agg(['mean', 'std', 'count'])
    )
    state_premium['ann_premium'] = state_premium['mean'] * 12
    state_premium['t_stat'] = (
        state_premium['mean']
        / (state_premium['std'] / np.sqrt(state_premium['count']))
    )
    
    print("Illiquidity Premium by Market State:")
    print(state_premium[['ann_premium', 't_stat', 'count']].round(3))
Bar chart of conditional illiquidity premium
fig, ax = plt.subplots(figsize=(8, 5))

if 'state_premium' in dir():
    colors_state = ['#C0392B', '#F1C40F', '#27AE60']
    bars = ax.bar(range(len(state_premium)),
                   state_premium['ann_premium'] * 100,
                   color=colors_state, alpha=0.85, edgecolor='white')
    ax.set_xticks(range(len(state_premium)))
    ax.set_xticklabels(state_premium.index)
    ax.set_ylabel('Annualized Q5-Q1 Return (%)')
    ax.set_title('Illiquidity Premium by Market State')
    ax.axhline(y=0, color='gray', linewidth=0.8)
    
    for i, (_, row) in enumerate(state_premium.iterrows()):
        ax.text(i, row['ann_premium'] * 100 + 0.3,
                f"t={row['t_stat']:.1f}",
                ha='center', fontsize=10)

plt.tight_layout()
plt.show()
Figure 14.6

14.10.2 Liquidity Co-Movement with Global Risk

Vietnamese market liquidity may be driven by global risk factors, particularly for stocks held by foreign investors. We test whether global risk measures (VIX, USD strength) predict Vietnamese aggregate liquidity:

Regress Vietnamese aggregate liquidity on global risk proxies
# Merge aggregate liquidity with global variables
global_vars = client.get_macro_data(
    variables=['vix_close', 'dxy_index'],
    start_date='2008-01-01',
    end_date='2024-12-31',
    frequency='monthly'
)

agg_liq_global = agg_liquidity.merge(
    global_vars, on='month_end', how='inner'
)

# Changes in all variables
for col in ['amihud_median', 'vix_close', 'dxy_index']:
    agg_liq_global[f'd_{col}'] = agg_liq_global[col].diff()

# Regression
y = agg_liq_global['d_amihud_median'].dropna()
X = sm.add_constant(
    agg_liq_global.loc[y.index, ['d_vix_close', 'd_dxy_index']]
)

model = sm.OLS(y, X).fit(cov_type='HAC', cov_kwds={'maxlags': 3})
print("Aggregate Illiquidity ~ Global Risk:")
print(model.summary().tables[1])

14.11 Constructing a Tradeable Liquidity Factor

Following Pástor and Stambaugh (2003), we construct an aggregate liquidity factor that can be used in asset pricing tests. The factor captures innovations in aggregate liquidity (unexpected changes in market-wide trading conditions).

Construct an aggregate liquidity innovation factor
# Step 1: Compute market-level Amihud as EW average
mkt_amihud = (
    panel.groupby('month_end')['amihud']
    .mean()
    .to_frame('mkt_amihud')
)

# Step 2: Estimate AR(2) model for aggregate illiquidity
mkt_amihud['mkt_amihud_lag1'] = mkt_amihud['mkt_amihud'].shift(1)
mkt_amihud['mkt_amihud_lag2'] = mkt_amihud['mkt_amihud'].shift(2)
mkt_amihud = mkt_amihud.dropna()

ar_model = sm.OLS(
    mkt_amihud['mkt_amihud'],
    sm.add_constant(mkt_amihud[['mkt_amihud_lag1', 'mkt_amihud_lag2']])
).fit()

# Step 3: Residuals = liquidity innovations
# Negative innovation = liquidity improved (good)
# Positive innovation = liquidity deteriorated (bad)
mkt_amihud['liq_innovation'] = ar_model.resid

# Step 4: Test whether liquidity innovations predict returns
# Higher sensitivity to negative innovations = higher expected return
print("AR(2) Model for Aggregate Amihud:")
print(f"  R-squared: {ar_model.rsquared:.3f}")
print(f"  AR(1) coef: {ar_model.params['mkt_amihud_lag1']:.3f} "
      f"(t={ar_model.tvalues['mkt_amihud_lag1']:.2f})")

# Step 5: Estimate liquidity betas for each firm
panel_liq_beta = panel.merge(
    mkt_amihud[['liq_innovation']],
    left_on='month_end', right_index=True, how='inner'
)

def estimate_liq_beta(group, min_obs=36):
    g = group.dropna(subset=['monthly_return', 'liq_innovation'])
    if len(g) < min_obs:
        return None
    y = g['monthly_return']
    X = sm.add_constant(g['liq_innovation'])
    try:
        model = sm.OLS(y, X).fit()
        return model.params['liq_innovation']
    except Exception:
        return None

liq_betas = (
    panel_liq_beta
    .groupby('ticker')
    .apply(estimate_liq_beta)
    .dropna()
    .to_frame('liq_beta')
)

print(f"\nLiquidity Beta Distribution:")
print(liq_betas['liq_beta'].describe().round(4))

14.12 Practical Guidance for Vietnam

The analysis in this chapter yields the following recommendations:

For researchers: The Amihud illiquidity ratio is the single best all-purpose liquidity proxy for Vietnamese equities. It has the highest coverage, the strongest cross-sectional return predictability, and the most robust relationship with firm size. When a second measure is needed for robustness, the zero-return proportion is the natural complement—it captures a different dimension (transaction cost threshold) and has near-complete coverage.

For portfolio construction: Any backtest of a Vietnamese equity strategy should compute and report estimated round-trip costs by quintile. Strategies that load on the bottom two size quintiles face costs of 2–5% per round trip, making monthly rebalancing uneconomical. Quarterly or annual rebalancing with a turnover constraint is more realistic.

For risk management: Monitor aggregate liquidity conditions using the cross-sectional median Amihud or the market-wide zero-return fraction. Liquidity deterioration predicts negative market returns and wider spreads in subsequent months. Tighten risk limits when aggregate illiquidity exceeds its 90th historical percentile.

For international comparisons: When comparing Vietnamese factor premia to U.S. or other developed market evidence, always report results on a “liquid universe” subset (top 60% by market cap) alongside the full sample. Many anomalies that appear large in the full sample shrink substantially when restricted to stocks that can actually be traded at scale.

14.13 Summary

Table Table 14.3 shows different measures of liquidity.

Table 14.3: Summary of liquidity measure properties in the Vietnamese market.
Measure Dimension Coverage Size Gradient Return Predictive Recommended Use
Amihud Price impact High Very steep Strong Primary proxy; portfolio sorts; Fama-MacBeth
Zero-Return Transaction cost Complete Steep Strong Robustness check; emerging market studies
Turnover Trading activity High Moderate Moderate Volume-based filters; flow analysis
Roll Spread Tightness Moderate Moderate Moderate Spread estimation without bid-ask data
Corwin-Schultz Tightness Moderate Moderate Moderate High-low based spread; calibration
Quoted Spread Tightness Variable Steep Strong Direct measure when available
Kyle Lambda Price impact Moderate Steep Strong Market microstructure research

Liquidity is not a secondary consideration for Vietnamese equity research; it is a first-order determinant of which strategies are implementable, which anomalies are real, and which results are artifacts of trading in stocks that cannot actually be traded. Every empirical finding in this book should be evaluated through the lens of the liquidity analysis developed in this chapter.