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:
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.
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).
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:
Tightness. The cost of turning around a position quickly, which is measured by the bid-ask spread.
Depth. The volume that can be traded without moving the price, which is related to price impact.
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 pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsimport statsmodels.api as smfrom scipy import statsfrom linearmodels.panel import PanelOLSfrom linearmodels.asset_pricing import LinearFactorModelimport warningswarnings.filterwarnings('ignore')plt.rcParams.update({'figure.figsize': (12, 6),'figure.dpi': 150,'font.size': 11,'axes.spines.top': False,'axes.spines.right': False})
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):
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:
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:
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.
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.
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.
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.
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
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.
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 inrange(40)]level_v = rng_v.lognormal(np.log(3e9), 1.1, size=len(tickers_v)) # VND/dayyears_v = (dates_v - dates_v[0]).days /365.25demo = 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 inzip(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:
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.
Volume in billions VND, positive-volume days only, minimum ten valid days per firm-month.
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.
Within-month ranks or quintiles for portfolio sorts, which as shown above are invariant to every other choice on this list.
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}\]
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.
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:
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):iflen(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 >0else np.nanelse:return0.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:
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):iflen(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]returnmax(lam, 0) # Lambda should be non-negativeexceptException: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:
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
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\).
(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:
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,
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
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),
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
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 samplingdef 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 >0else 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))returndict(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 holdscore(simulate_days(30_000, M_HOSE), "Baseline (A1-A4 hold), M=45")# Failure case A: microstructure noise, and what oversampling does to itscore(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 jumpsscore(simulate_days(30_000, M_HOSE, jump_prob=0.2, jump_sd=0.03),"Information jumps (~1 per 5 days, 3% size)")
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
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.
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.
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.
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.
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.
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.
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.
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.
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
Given the multidimensionality of liquidity, we extract a composite liquidity factor using PCA:
Extract principal components from standardized liquidity measures
from sklearn.preprocessing import StandardScalerfrom 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 measureliq_complete = panel.dropna(subset=pca_cols).copy()# Cross-sectional standardization by monthdef 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 dfliq_complete = standardize_within_month(liq_complete, pca_cols)z_cols = [c +'_z'for c in pca_cols]# Pool all months for PCApca_input = liq_complete[z_cols].dropna()pca = PCA(n_components=3)pca.fit(pca_input)print("PCA Explained Variance Ratios:")for i, (var, cumvar) inenumerate(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 inzip(pca_cols, pca.components_[0]):print(f" {col:<20}: {loading:.3f}")# Assign PC1 as composite illiquidityliq_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.
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:
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 marketpanel_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 firmdef estimate_commonality(group, min_obs=24): g = group.dropna(subset=['d_amihud', 'd_amihud_mkt'])iflen(g) < min_obs:returnNone 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 })exceptException:returnNonecommonality = ( 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 -1in port_returns.columns and0in port_returns.columns: port_returns['long_short'] = ( port_returns[n_groups -1] - port_returns[0] )return port_returns# Run sorts for each illiquidity measuresort_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 liquidpanel_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() *12if4in ports.columns else np.nan ls = ports['long_short'].mean() *12if'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 >0else np.nanprint(f"{name:<18}{q1:>12.4f}{q5:>14.4f}{ls:>10.4f}{t:>8.2f}")
Bar chart of quintile returns by liquidity measure
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:
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 controlsfor 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]iflen(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 inenumerate(regressors): gammas[reg] = model.params[j +1] gamma_list.append(gammas)exceptException: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 >0else np.nan results[col] = {'Coefficient': mean, 'SE': se, 't-stat': t}return pd.DataFrame(results).T, gamma_df# Run for each illiquidity measureprint("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
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 approximationreturn explicit + spread_cost + impactpanel['estimated_rtc'] = panel.apply(estimate_round_trip_cost, axis=1)# Distribution by size quintilertc_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: Distributionfor q, color inzip(['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 costcost_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 returnsfor name, ports in sort_results.items():if'long_short'notin 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 *12print(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.
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 variablesglobal_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 variablesfor col in ['amihud_median', 'vix_close', 'dxy_index']: agg_liq_global[f'd_{col}'] = agg_liq_global[col].diff()# Regressiony = 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 averagemkt_amihud = ( panel.groupby('month_end')['amihud'] .mean() .to_frame('mkt_amihud'))# Step 2: Estimate AR(2) model for aggregate illiquiditymkt_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 returnprint("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 firmpanel_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'])iflen(g) < min_obs:returnNone y = g['monthly_return'] X = sm.add_constant(g['liq_innovation'])try: model = sm.OLS(y, X).fit()return model.params['liq_innovation']exceptException:returnNoneliq_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.
Acharya, Viral V, and Lasse Heje Pedersen. 2005. “Asset Pricing with Liquidity Risk.”Journal of Financial Economics 77 (2): 375–410.
Aït-Sahalia, Yacine, Per A. Mykland, and Lan Zhang. 2005. “How Often to Sample a Continuous-Time Process in the Presence of Market Microstructure Noise.”The Review of Financial Studies 18 (2): 351–416. https://doi.org/10.1093/rfs/hhi016.
Amihud, Yakov. 2002. “Illiquidity and Stock Returns: Cross-Section and Time-Series Effects.”Journal of Financial Markets 5 (1): 31–56. https://doi.org/10.1016/S1386-4181(01)00024-6.
Amihud, Yakov, Allaudeen Hameed, Wenjin Kang, and Huiping Zhang. 2015. “The Illiquidity Premium: International Evidence.”Journal of Financial Economics 117 (2): 350–68. https://doi.org/10.1016/j.jfineco.2015.04.005.
Amihud, Yakov, and Haim Mendelson. 1986. “Asset Pricing and the Bid-Ask Spread.”Journal of Financial Economics 17 (2): 223–49.
Amihud, Yakov, Haim Mendelson, and Lasse Heje Pedersen. 2005. “Liquidity and Asset Prices.”Foundations and Trends in Finance 1 (4): 269–364. https://doi.org/10.1561/0500000003.
Amihud, Yakov, and Joonki Noh. 2021. “Illiquidity and Stock Returns II: Cross-Section and Time-Series Effects.”The Review of Financial Studies 34 (4): 2101–23. https://doi.org/10.1093/rfs/hhaa080.
Andersen, Torben G., Tim Bollerslev, Francis X. Diebold, and Paul Labys. 2003. “Modeling and Forecasting Realized Volatility.”Econometrica 71 (2): 579–625. https://doi.org/10.1111/1468-0262.00418.
Andersen, Torben G., Dobrislav Dobrev, and Ernst Schaumburg. 2012. “Jump-Robust Volatility Estimation Using Nearest Neighbor Truncation.”Journal of Econometrics 169 (1): 75–93. https://doi.org/10.1016/j.jeconom.2012.01.011.
Bandi, Federico M., and Jeffrey R. Russell. 2008. “Microstructure Noise, Realized Variance, and Optimal Sampling.”The Review of Economic Studies 75 (2): 339–69. https://doi.org/10.1111/j.1467-937X.2008.00474.x.
Barndorff-Nielsen, Ole E., Peter Reinhard Hansen, Asger Lunde, and Neil Shephard. 2008. “Designing Realized Kernels to Measure the Ex Post Variation of Equity Prices in the Presence of Noise.”Econometrica 76 (6): 1481–1536. https://doi.org/10.3982/ECTA6495.
Barndorff-Nielsen, Ole E., and Neil Shephard. 2002. “Econometric Analysis of Realized Volatility and Its Use in Estimating Stochastic Volatility Models.”Journal of the Royal Statistical Society Series B: Statistical Methodology 64 (2): 253–80. https://doi.org/10.1111/1467-9868.00336.
———. 2004. “Power and Bipower Variation with Stochastic Volatility and Jumps.”Journal of Financial Econometrics 2 (1): 1–37. https://doi.org/10.1093/jjfinec/nbh001.
Brennan, Michael J., and Avanidhar Subrahmanyam. 1996. “Market Microstructure and Asset Pricing: On the Compensation for Illiquidity in Stock Returns.”Journal of Financial Economics 41 (3): 441–64. https://doi.org/10.1016/0304-405X(95)00870-K.
Brunnermeier, Markus K, and Lasse Heje Pedersen. 2009. “Market Liquidity and Funding Liquidity.”The Review of Financial Studies 22 (6): 2201–38.
Chordia, Tarun, Richard Roll, and Avanidhar Subrahmanyam. 2000. “Commonality in Liquidity.”Journal of Financial Economics 56 (1): 3–28.
———. 2001. “Market Liquidity and Trading Activity.”The Journal of Finance 56 (2): 501–30.
Corwin, Shane A, and Paul Schultz. 2012. “A Simple Way to Estimate Bid-Ask Spreads from Daily High and Low Prices.”The Journal of Finance 67 (2): 719–60.
Datar, Vinay T, Narayan Y Naik, and Robert Radcliffe. 1998. “Liquidity and Stock Returns: An Alternative Test.”Journal of Financial Markets 1 (2): 203–19.
Fama, Eugene F., and Kenneth R. French. 1993. “Common risk factors in the returns on stocks and bonds.”Journal of Financial Economics 33 (1): 3–56. https://doi.org/10.1016/0304-405X(93)90023-5.
Fong, Kingsley YL, Craig W Holden, and Charles A Trzcinka. 2017. “What Are the Best Liquidity Proxies for Global Research?”Review of Finance 21 (4): 1355–1401.
Goyenko, Ruslan Y, Craig W Holden, and Charles A Trzcinka. 2009. “Do Liquidity Measures Measure Liquidity?”Journal of Financial Economics 92 (2): 153–81.
Goyenko, Ruslan Y, and Andrey D Ukhov. 2009. “Stock and Bond Market Liquidity: A Long-Run Empirical Analysis.”Journal of Financial and Quantitative Analysis 44 (1): 189–212.
Hameed, Allaudeen, Wenjin Kang, and Shivesh Viswanathan. 2010. “Stock Market Declines and Liquidity.”The Journal of Finance 65 (1): 257–93.
Hansen, Peter R., and Asger Lunde. 2006. “Realized Variance and Market Microstructure Noise.”Journal of Business & Economic Statistics 24 (2): 127–61. https://doi.org/10.1198/073500106000000071.
Hasbrouck, Joel. 2009. “Trading Costs and Returns for US Equities: Estimating Effective Costs from Daily Data.”The Journal of Finance 64 (3): 1445–77. https://doi.org/10.1111/j.1540-6261.2009.01469.x.
Jacod, Jean, Yingying Li, Per A. Mykland, Mark Podolskij, and Mathias Vetter. 2009. “Microstructure Noise in the Continuous Case: The Pre-Averaging Approach.”Stochastic Processes and Their Applications 119 (7): 2249–76. https://doi.org/10.1016/j.spa.2008.11.004.
Karolyi, G. Andrew, Kuan-Hui Lee, and Mathijs A. van Dijk. 2012. “Understanding Commonality in Liquidity Around the World.”Journal of Financial Economics 105 (1): 82–112. https://doi.org/10.1016/j.jfineco.2011.12.008.
Kyle, Albert S. 1985. “Continuous Auctions and Insider Trading.”Econometrica: Journal of the Econometric Society 53 (6): 1315–35. https://doi.org/10.2307/1913210.
Lesmond, David A. 2005. “Liquidity of Emerging Markets.”Journal of Financial Economics 77 (2): 411–52.
Lesmond, David A, Joseph P Ogden, and Charles A Trzcinka. 1999. “A New Estimate of Transaction Costs.”The Review of Financial Studies 12 (5): 1113–41.
Liu, Lily Y., Andrew J. Patton, and Kevin Sheppard. 2015. “Does Anything Beat 5-Minute RV? A Comparison of Realized Measures Across Multiple Asset Classes.”Journal of Econometrics 187 (1): 293–311. https://doi.org/10.1016/j.jeconom.2015.02.008.
Lou, Xiaoxia, and Tao Shu. 2017. “Price Impact or Trading Volume: Why Is the Amihud (2002) Measure Priced?”The Review of Financial Studies 30 (12): 4481–4520. https://doi.org/10.1093/rfs/hhx072.
Pástor, L’uboš, and Robert F Stambaugh. 2003. “Liquidity Risk and Expected Stock Returns.”Journal of Political Economy 111 (3): 642–85. https://doi.org/10.1086/374184.
Zhang, Lan, Per A. Mykland, and Yacine Aït-Sahalia. 2005. “A Tale of Two Time Scales: Determining Integrated Volatility with Noisy High-Frequency Data.”Journal of the American Statistical Association 100 (472): 1394–1411. https://doi.org/10.1198/016214505000000169.
# Liquidity and Turnover Measures::: callout-noteIn 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.## Theoretical Foundations {#sec-liquidity-theory}### Why Liquidity MattersLiquidity affects asset prices through at least three channels:1. **Level effect.** Investors demand compensation for the expected cost of trading. @amihud1986asset 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. @pastor2003liquidity show that stocks whose returns are more sensitive to aggregate liquidity shocks earn higher expected returns. @acharya2005asset 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.** @chordia2000commonality document that individual stock liquidity co-moves strongly with market-wide liquidity. @brunnermeier2009market 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.### Liquidity Dimensions@kyle1985continuous 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. @goyenko2009liquidity and @fong2017best systematically evaluate which low-frequency proxies best capture each dimension by benchmarking against high-frequency measures. Their key finding: the @amihud2002illiquidity measure best captures the price impact dimension, the @roll1984simple estimator and @corwin2012simple spread best capture tightness, and the @lesmond1999new zero-return measure captures a blend of transaction costs and information asymmetry. For emerging markets specifically, @fong2017best recommend the Amihud measure and the Closing Percent Quoted Spread as the most reliable proxies.## Data Construction {#sec-liquidity-data}```{python}#| label: setup#| code-summary: "Import libraries and configure environment"import pandas as pdimport numpy as npimport matplotlib.pyplot as pltimport seaborn as snsimport statsmodels.api as smfrom scipy import statsfrom linearmodels.panel import PanelOLSfrom linearmodels.asset_pricing import LinearFactorModelimport warningswarnings.filterwarnings('ignore')plt.rcParams.update({'figure.figsize': (12, 6),'figure.dpi': 150,'font.size': 11,'axes.spines.top': False,'axes.spines.right': False})``````{python}#| label: data-load#| code-summary: "Load daily and monthly data"#| eval: falsefrom datacore import DataCoreClientclient = DataCoreClient()# Daily trading datadaily = 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 aggregatesmonthly = 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 testsfundamentals = 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 testsfactors = 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()}")``````{python}#| label: daily-returns#| code-summary: "Compute daily returns and basic trading statistics"#| eval: false# Daily returnsdaily['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 indicatorsdaily['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'] /1e9print("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%}")```## Constructing Liquidity Measures {#sec-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.### Amihud Illiquidity RatioThe @amihud2002illiquidity 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}}$$ {#eq-amihud}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 @kyle1985continuous 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$.```{python}#| label: amihud#| code-summary: "Compute monthly Amihud illiquidity ratio"#| eval: falsedef 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))```#### The Original 2002 Definition {#sec-liquidity-amihud-original}@eq-amihud is the *modern* form of the measure, not the one @amihud2002illiquidity 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}}$$ {#eq-amihud-original}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 @eq-amihud 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} $$ {#eq-amihud-ma} 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.#### Precursor and Theory: Why a Return-to-Volume RatioThe measure did not appear from nowhere. @amihud1986asset 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. @amihud2005liquidity survey the resulting literature.The theoretical warrant comes from @kyle1985continuous. 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. @eq-amihud is that ratio computed with *unsigned* order flow, which is all daily data provides. @brennan1996market show directly that price-impact estimates constructed from intraday signed order flow carry a return premium, and @goyenko2009liquidity and @goyenko2009stock later establish that the Amihud ratio is a good low-frequency proxy for those high-frequency price-impact benchmarks. @fong2017best 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.#### The Variant Lineage@tbl-amihud-variants 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.| 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$ | @amihud2002illiquidity, dollar volume in dollars || Mean-adjusted | $\text{ILLIQ}_{i}/\overline{\text{ILLIQ}}$ | @amihud2002illiquidity; unit-free, detrended (@eq-amihud-ma) || Log | $\log(\text{ILLIQ})$ or $\log(1+\text{ILLIQ})$ | Variance stabilization; the convention used in the code above || Square root | $\sqrt{\text{ILLIQ}}$ | @hasbrouck2009trading; closer in units to an effective cost || Normalized cost | $\min(a + b\,\text{ILLIQ}\,P^M_{t-1},\,\bar{c})$ | @acharya2005asset; maps to a percentage cost, capped || Within-period rank | Percentile of $\text{ILLIQ}$ in period $t$ | @karolyi2012understanding, @amihud2015illiquidity; cross-market comparability |: Transformations of the Amihud illiquidity ratio in common use. All are strictly increasing in raw $\text{ILLIQ}$ within a given period. {#tbl-amihud-variants}Two of these deserve elaboration. The @acharya2005asset 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 [@karolyi2012understanding; @amihud2015illiquidity] 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 @amihud2021illiquidity, 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.#### Scaling Is Not InnocuousThe 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.```{python}#| label: amihud-variants#| code-summary: "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 inrange(40)]level_v = rng_v.lognormal(np.log(3e9), 1.1, size=len(tickers_v)) # VND/dayyears_v = (dates_v - dates_v[0]).days /365.25demo = 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 inzip(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}%")```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 @tbl-amihud-variants 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 @acharya2005asset 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:```{python}#| label: amihud-variants-real#| eval: false#| code-summary: "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.#### What Is Actually Being Priced: The Lou--Shu CritiqueThere is a deeper objection to @eq-amihud than any question of scaling, and it bears directly on the high-frequency refinement developed in @sec-liquidity-realized.@lou2017price 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 @sec-liquidity-realized 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 @lacava2026realized 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 @sec-liquidity-realized.#### Which Variant This Book UsesFor 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 @eq-amihud 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.### Zero-Return Days (Lesmond Measure)@lesmond1999new 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.@lesmond2005liquidity 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}}$$ {#eq-zero-return}```{python}#| label: zero-return#| eval: false#| code-summary: "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))```### Turnover RatioShare turnover (i.e., daily volume divided by shares outstanding) measures trading activity rather than trading cost. @datar1998liquidity 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}}$$ {#eq-turnover}```{python}#| label: turnover#| code-summary: "Compute monthly average turnover ratio"#| eval: falsedef 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))```### Roll Spread Estimator@roll1984simple 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}$$ {#eq-roll}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.```{python}#| label: roll-spread#| eval: false#| code-summary: "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):iflen(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 >0else np.nanelse:return0.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))```### Corwin-Schultz High-Low Spread@corwin2012simple 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}}}$$ {#eq-corwin-schultz}where:$$\hat{\alpha} = \frac{\sqrt{2\hat{\beta}} - \sqrt{\hat{\beta}}}{3 - 2\sqrt{2}} - \sqrt{\frac{\hat{\gamma}}{3 - 2\sqrt{2}}}$$ {#eq-cs-alpha}with $\hat{\beta}$ and $\hat{\gamma}$ computed from one-day and two-day log high-low ratios.```{python}#| label: corwin-schultz#| eval: false#| code-summary: "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'] **2def cs_estimate(group):iflen(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)) / denomif 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 estimateif alpha_est >0: spread =2* (np.exp(alpha_est) -1) / (1+ np.exp(alpha_est))else: spread =0returnmin(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))```### Quoted Bid-Ask SpreadWhen 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}$$ {#eq-quoted-spread}```{python}#| label: quoted-spread#| eval: false#| code-summary: "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))```### 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}$$ {#eq-kyle-lambda}This is an adaptation of the @hasbrouck2009trading effective cost measure. The coefficient $\lambda_i$ measures how much prices move per unit of (unsigned, square-rooted) volume.```{python}#| label: kyle-lambda#| eval: false#| code-summary: "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):iflen(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]returnmax(lam, 0) # Lambda should be non-negativeexceptException: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))```## Realized Illiquidity: A High-Frequency Refinement of Amihud {#sec-liquidity-realized}### MotivationOf the seven proxies constructed above, the @amihud2002illiquidity ratio in @eq-amihud 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 @fong2017best 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.@lacava2026realized 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.### Setup and NotationLet $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}$$ {#eq-semimartingale}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$$ {#eq-integrated-variance}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|$$ {#eq-rv-bv}Both are consistent for $\text{IV}_{i,d}$ as $M \to \infty$ in the absence of jumps [@barndorff2002econometric; @andersen2003modeling]; $\text{BV}_{i,d}$ remains consistent *with* jumps present, which is what makes it useful below [@barndorff2004power].### Assumptions**(A1) Semimartingale efficient price.** The efficient log price follows @eq-semimartingale 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.### The EstimatorThe 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}}$$ {#eq-realized-amihud}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}}$$ {#eq-realized-amihud-bv}The constant $\sqrt{2/\pi} \approx 0.7979$ is not cosmetic: it puts @eq-realized-amihud on exactly the same scale as @eq-amihud, so the two are numerically comparable and the realized version is a drop-in replacement everywhere the book currently uses $\text{ILLIQ}$.### 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}$$ {#eq-halfnormal}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$$ {#eq-abs-relsd}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 @barndorff2002econometric,$$\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$$ {#eq-rv-clt}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)$$ {#eq-sqrt-rv-clt}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}}$$ {#eq-rv-relsd}**Step 3: the comparison.** Dividing @eq-abs-relsd by @eq-rv-relsd, 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$:| 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$ |: Analytical measurement error of the Amihud numerator by sampling frequency, from @eq-abs-relsd and @eq-rv-relsd. The 1-minute row is *not* a recommendation — see assumption (A2) and the simulation below. {#tbl-realized-precision}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]}$$ {#eq-attenuation}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.### IntuitionAmihud'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.### Simulation Design {#sec-liquidity-realized-sim}The following experiment benchmarks @eq-realized-amihud against @eq-amihud 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.```{python}#| label: realized-amihud-simulation#| code-summary: "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 samplingdef 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 >0else 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))returndict(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 holdscore(simulate_days(30_000, M_HOSE), "Baseline (A1-A4 hold), M=45")# Failure case A: microstructure noise, and what oversampling does to itscore(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 jumpsscore(simulate_days(30_000, M_HOSE, jump_prob=0.2, jump_sd=0.03),"Information jumps (~1 per 5 days, 3% size)")```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 @eq-halfnormal 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 @tbl-realized-precision 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 @eq-realized-amihud-bv 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.### Replication on Vietnamese Data@lacava2026realized 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 @eq-zero-return, 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 @eq-amihud 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.### Practical Guidance1. **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: @tbl-realized-precision 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 [@andersen2003modeling], the optimal-sampling calculations of @aitsahalia2005often and @bandi2008microstructure land in the same neighbourhood for liquid US equities, and @liu2015does 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 @eq-amihud 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 @eq-realized-amihud-bv is the version that survives them.6. **Escalate to a noise-robust estimator only if you must sample finely.** The estimators in @eq-rv-bv 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 @zhang2005tale, which subsamples at two frequencies and extrapolates the noise away; the pre-averaging approach of @jacod2009microstructure, which averages returns within blocks before squaring; and the realized kernels of @barndorff2008designing, which weight autocovariances of intraday returns. @hansen2006realized 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.** @andersen2012jump 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.### Pitfalls- **Oversampling.** The single most common error. More observations look like more information, but @eq-rv-clt 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 @eq-realized-amihud roughly 25% larger than @eq-amihud, 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.** @eq-attenuation 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.## Assembling the Liquidity Panel {#sec-liquidity-panel}We merge all seven measures into a single firm-month panel for comparative analysis.```{python}#| label: merge-panel#| eval: false#| code-summary: "Merge all liquidity measures into a unified panel"# Start with monthly returns as the basepanel = monthly[['ticker', 'month_end', 'monthly_return','market_cap']].copy()# Merge each liquidity measurefor 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 cappanel['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'] +1panel = 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%}")```## Cross-Sectional Properties of Liquidity {#sec-liquidity-cross-section}### Summary Statistics by Size QuintileLiquidity varies enormously across the size distribution. Small-cap Vietnamese stocks can be orders of magnitude less liquid than large-caps.```{python}#| label: liquidity-by-size#| eval: false#| code-summary: "Compute liquidity statistics by market cap quintile"# Assign size quintiles within each monthpanel['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 quintileliq_by_size = ( panel.groupby('size_quintile')[liquidity_cols] .mean() .round(4))print("Average Liquidity by Market Cap Quintile:")print(liq_by_size.to_string())``````{python}#| label: fig-liquidity-by-size#| eval: false#| fig-cap: "Liquidity measures by market capitalization quintile. All measures confirm the strong negative relationship between size and illiquidity: the smallest quintile is 10--50x more illiquid than the largest by any metric. This gradient is steeper in Vietnam than in developed markets, reflecting the extreme heterogeneity of the listed universe."#| code-summary: "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) inenumerate(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()```### Correlation StructureHow 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.```{python}#| label: fig-liquidity-correlations#| eval: false#| fig-cap: "Pairwise rank correlations among liquidity measures. The measures are positively correlated (all point in the same direction—higher values indicate lower liquidity, except turnover where higher means more liquid), but correlations are far from perfect. This confirms that liquidity is multidimensional: each measure captures a different aspect of trading friction."#| code-summary: "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_labelsrank_corr.columns = corr_labelsfig, 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()```### Principal Component Analysis of LiquidityGiven the multidimensionality of liquidity, we extract a composite liquidity factor using PCA:```{python}#| label: pca-liquidity#| eval: false#| code-summary: "Extract principal components from standardized liquidity measures"from sklearn.preprocessing import StandardScalerfrom 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 measureliq_complete = panel.dropna(subset=pca_cols).copy()# Cross-sectional standardization by monthdef 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 dfliq_complete = standardize_within_month(liq_complete, pca_cols)z_cols = [c +'_z'for c in pca_cols]# Pool all months for PCApca_input = liq_complete[z_cols].dropna()pca = PCA(n_components=3)pca.fit(pca_input)print("PCA Explained Variance Ratios:")for i, (var, cumvar) inenumerate(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 inzip(pca_cols, pca.components_[0]):print(f" {col:<20}: {loading:.3f}")# Assign PC1 as composite illiquidityliq_complete['illiq_pc1'] = pca.transform( liq_complete[z_cols].values)[:, 0]```## Aggregate Liquidity and Market Conditions {#sec-liquidity-aggregate}### Time Series of Market LiquidityAggregate liquidity (i.e., the average illiquidity across all stocks) varies substantially over time. @chordia2001market document that market-wide liquidity declines during periods of high volatility and negative market returns.```{python}#| label: fig-aggregate-liquidity#| eval: false#| fig-cap: "Aggregate liquidity conditions in the Vietnamese equity market. Panel A shows the cross-sectional median of three illiquidity measures over time (standardized). Panel B shows the VN-Index return for context. Liquidity deteriorated sharply during the 2008--2009 global financial crisis, the 2011 domestic tightening cycle, and the March 2020 COVID crash. Each episode is marked by simultaneous spikes in all illiquidity measures."#| code-summary: "Plot aggregate liquidity time series"# Compute monthly cross-sectional aggregatesagg_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 plottingfor 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 illiquidityaxes[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 periodscrisis_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 returnmarket_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 >0else'#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()```### Commonality in Liquidity@chordia2000commonality 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}$$ {#eq-commonality}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.```{python}#| label: commonality#| eval: false#| code-summary: "Test for commonality in liquidity across Vietnamese stocks"# Compute monthly changes in Amihud for each firm and the marketpanel_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 firmdef estimate_commonality(group, min_obs=24): g = group.dropna(subset=['d_amihud', 'd_amihud_mkt'])iflen(g) < min_obs:returnNone 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 })exceptException:returnNonecommonality = ( 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}")```## Is Liquidity Priced? {#sec-liquidity-pricing}### Portfolio SortsWe test whether illiquidity predicts future returns by sorting stocks into quintile portfolios based on lagged liquidity measures and comparing average returns across quintiles.```{python}#| label: portfolio-sorts#| eval: false#| code-summary: "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 -1in port_returns.columns and0in port_returns.columns: port_returns['long_short'] = ( port_returns[n_groups -1] - port_returns[0] )return port_returns# Run sorts for each illiquidity measuresort_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 liquidpanel_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() *12if4in ports.columns else np.nan ls = ports['long_short'].mean() *12if'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 >0else np.nanprint(f"{name:<18}{q1:>12.4f}{q5:>14.4f}{ls:>10.4f}{t:>8.2f}")``````{python}#| label: fig-liquidity-premium#| eval: false#| fig-cap: "Annualized average returns by illiquidity quintile for each liquidity measure. A monotonically increasing pattern from Q1 (most liquid) to Q5 (most illiquid) indicates a liquidity premium—compensation for holding harder-to-trade stocks."#| code-summary: "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) inenumerate(sort_results.items()):if i >=5:break quintile_means = [ports[q].mean() *12*100for q inrange(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 inrange(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 subplotiflen(sort_results) <6: axes[5].set_visible(False)plt.suptitle('Average Returns by Illiquidity Quintile', fontsize=14)plt.tight_layout()plt.show()```### Fama-MacBeth Cross-Sectional RegressionsPortfolio sorts are informative but cannot control for multiple characteristics simultaneously. We use @fama1993common -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}$$ {#eq-fama-macbeth}The time-series average of the monthly coefficient $\bar{\gamma}_1$ estimates the illiquidity premium, and its t-statistic uses the @fama1993common standard error.```{python}#| label: fama-macbeth#| eval: false#| code-summary: "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 controlsfor 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]iflen(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 inenumerate(regressors): gammas[reg] = model.params[j +1] gamma_list.append(gammas)exceptException: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 >0else np.nan results[col] = {'Coefficient': mean, 'SE': se, 't-stat': t}return pd.DataFrame(results).T, gamma_df# Run for each illiquidity measureprint("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())```### Factor-Adjusted Liquidity PremiumThe 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:```{python}#| label: factor-adjusted#| eval: false#| code-summary: "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'notin 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')iflen(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.rsquaredprint(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}")```## Liquidity and Transaction Cost Estimation {#sec-liquidity-implementation}### Translating Measures to Trading CostsFor 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.```{python}#| label: cost-calibration#| eval: false#| code-summary: "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 approximationreturn explicit + spread_cost + impactpanel['estimated_rtc'] = panel.apply(estimate_round_trip_cost, axis=1)# Distribution by size quintilertc_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())``````{python}#| label: fig-cost-distribution#| eval: false#| fig-cap: "Estimated round-trip trading costs by market cap quintile. Small-cap Vietnamese stocks have estimated costs of 3--8%, making high-frequency rebalancing strategies uneconomical. Even large-cap stocks carry costs of 0.5--1.5%, substantially higher than in developed markets."#| code-summary: "Plot estimated trading costs by size quintile"fig, axes = plt.subplots(1, 2, figsize=(14, 5))# Panel A: Distributionfor q, color inzip(['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 costcost_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()```### Strategy ImplementabilityA 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.```{python}#| label: net-of-cost#| eval: false#| code-summary: "Compute net-of-cost returns for illiquidity-sorted portfolios"# For each quintile, estimate average monthly turnover and cost# and subtract from gross returnsfor name, ports in sort_results.items():if'long_short'notin 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 *12print(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}%")```## Liquidity During Market Stress {#sec-liquidity-stress}### Flight to LiquidityDuring 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. @hameed2010stock show that this pattern is strongest when market returns are most negative.```{python}#| label: flight-to-liquidity#| eval: false#| code-summary: "Test for flight-to-liquidity during market stress"# Merge Amihud-sorted portfolio returns with market returnsamihud_ports = sort_results.get('Amihud')if amihud_ports isnotNoneand'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))``````{python}#| label: fig-flight-to-liquidity#| eval: false#| fig-cap: "Illiquidity premium (Q5 minus Q1 Amihud) conditional on market returns. In bear markets, illiquid stocks underperform liquid stocks---the illiquidity premium reverses. In normal and bull markets, the premium is positive. This asymmetry reflects flight-to-liquidity behavior: investors flee illiquid stocks during stress, depressing their prices and generating negative contemporaneous returns."#| code-summary: "Bar chart of conditional illiquidity premium"fig, ax = plt.subplots(figsize=(8, 5))if'state_premium'indir(): 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) inenumerate(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()```### Liquidity Co-Movement with Global RiskVietnamese 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:```{python}#| label: global-risk#| eval: false#| code-summary: "Regress Vietnamese aggregate liquidity on global risk proxies"# Merge aggregate liquidity with global variablesglobal_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 variablesfor col in ['amihud_median', 'vix_close', 'dxy_index']: agg_liq_global[f'd_{col}'] = agg_liq_global[col].diff()# Regressiony = 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])```## Constructing a Tradeable Liquidity Factor {#sec-liquidity-factor}Following @pastor2003liquidity, 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).```{python}#| label: liquidity-factor#| eval: false#| code-summary: "Construct an aggregate liquidity innovation factor"# Step 1: Compute market-level Amihud as EW averagemkt_amihud = ( panel.groupby('month_end')['amihud'] .mean() .to_frame('mkt_amihud'))# Step 2: Estimate AR(2) model for aggregate illiquiditymkt_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 returnprint("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 firmpanel_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'])iflen(g) < min_obs:returnNone y = g['monthly_return'] X = sm.add_constant(g['liq_innovation'])try: model = sm.OLS(y, X).fit()return model.params['liq_innovation']exceptException:returnNoneliq_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))```## Practical Guidance for Vietnam {#sec-liquidity-practical}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.## Summary {#sec-liquidity-summary}Table @tbl-liquidity-summary shows different measures of liquidity.| 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 |: Summary of liquidity measure properties in the Vietnamese market. {#tbl-liquidity-summary}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.<!-- ## Exercises {#sec-liquidity-exercises}1. **Closing percent quoted spread.** @fong2017best recommend the closing percent quoted spread as the best global liquidity proxy. Compute this as the closing bid-ask spread divided by the midpoint, averaged over the month. Compare its correlation with the Amihud measure and the Corwin-Schultz spread. Does the recommendation hold for Vietnam?2. **Liquidity-adjusted momentum.** Compute momentum portfolio returns (12-1 month) separately within each Amihud illiquidity tercile. Is the momentum premium concentrated in illiquid stocks? What does this imply about the implementability of momentum strategies in Vietnam? Connect your findings to @novy2016taxonomy.3. **Foreign investor liquidity demand.** Using foreign buy and sell volume data, compute a net foreign flow measure for each stock. Test whether foreign net buying predicts next-month liquidity improvements (lower Amihud, lower spreads). Does foreign ownership create a liquidity externality?4. **Intraday liquidity patterns.** If intraday data are available, compute the Amihud ratio separately for the opening call auction, the morning continuous session, and the afternoon continuous session. How does intraday liquidity variation compare to cross-sectional variation?5. **Liquidity and earnings announcements.** Test whether liquidity deteriorates before and improves after quarterly earnings announcements. Use an event study framework with the Amihud ratio as the dependent variable. Is the pre-announcement illiquidity increase consistent with the @glosten1985bid adverse selection model?6. **Market-making profitability.** Using the Roll spread as a proxy for the bid-ask bounce, estimate the gross profit a hypothetical market maker would earn from providing liquidity in Vietnamese stocks. How does this compare to the explicit cost of capital for market making?7. **Liquidity factor mimicking portfolio.** Construct a tradeable liquidity factor by sorting stocks into deciles based on their liquidity beta (sensitivity to aggregate liquidity innovations). The long-short return of this factor should be correlated with aggregate liquidity changes. Test whether this factor earns a significant premium and whether it explains returns beyond the Fama-French factors.8. **COVID-19 liquidity shock.** Analyze the liquidity dynamics around the March 2020 COVID-19 market crash. How quickly did aggregate liquidity deteriorate? Which firms experienced the largest liquidity drops? Did the SBV's policy responses (rate cuts, reserve requirement reductions) improve liquidity, and with what lag? -->