import pandas as pd
import numpy as np
from scipy import stats, optimize
from scipy.optimize import minimize, minimize_scalar
import statsmodels.api as sm
import statsmodels.formula.api as smf
from linearmodels.panel import PanelOLS
from linearmodels.iv import IV2SLS
import plotnine as p9
from mizani.formatters import percent_format, comma_format
import warnings
warnings.filterwarnings("ignore")43 Structural Models in Finance
Most of empirical finance is reduced-form: regress returns on characteristics, estimate risk premia from factor portfolios, test whether a coefficient is significantly different from zero. Structural estimation takes a fundamentally different approach. It begins with an explicit economic model (i.e., specifying agents’ preferences, information sets, constraints, and optimization problems) and estimates the model’s primitive parameters directly from observed data. The payoff is the ability to perform counterfactual analysis: what would happen to prices if trading costs fell by half? How would IPO underpricing change if the allocation mechanism switched from book building to a uniform-price auction? What is the welfare cost of a particular market design choice? These questions are unanswerable within a reduced-form framework because they require knowledge of the data-generating process, not merely statistical associations within a single regime.
This chapter introduces the key structural estimation frameworks used in financial economics and implements them for Vietnamese equity markets. We cover four domains where structural models have proven most productive: investor demand estimation, trading cost and execution models, primary market auctions, and limit order book dynamics. Each domain involves distinct economic primitives such as preferences, beliefs, transaction costs, information asymmetries and each requires domain-specific identification strategies.
Vietnamese markets offer both challenges and opportunities for structural estimation. On the challenge side, data quality is uneven, market microstructure is evolving, and institutional features (price limits, foreign ownership caps, state ownership) introduce constraints that standard models do not accommodate. On the opportunity side, several features of Vietnamese markets create natural variation that aids identification: the coexistence of two exchanges (HOSE and HNX) with different trading rules, regulatory changes that shift trading costs and price limits, and the phased liberalization of foreign ownership caps that generates exogenous demand shocks.
43.1 What Structural Estimation Means in Finance
43.1.1 Reduced Form Versus Structural
Consider two researchers studying the effect of transaction costs on trading volume. The reduced-form researcher exploits a fee reduction event and estimates:
\[ \ln V_{i,t} = \alpha + \beta \cdot \text{Post}_t + \gamma \mathbf{X}_{i,t} + \varepsilon_{i,t} \tag{43.1}\]
The coefficient \(\beta\) identifies the causal effect of the fee change on volume, but it is local to this specific fee change, this specific market, and this specific time period. It says nothing about what a different fee change would do, or what the optimal fee structure might be.
The structural researcher writes down an explicit model of trader behavior:
\[ \max_{q_t} \; E_t\left[\sum_{s=t}^{T} \delta^{s-t}\left(v_s q_s - c(q_s; \boldsymbol{\theta})\right)\right] \tag{43.2}\]
where \(v_s\) is the (possibly private) valuation, \(q_s\) is the trade quantity, \(c(\cdot; \boldsymbol{\theta})\) is the cost function parameterized by \(\boldsymbol{\theta}\), and \(\delta\) is the discount factor. The researcher estimates \(\boldsymbol{\theta}\) by requiring that the model’s predictions match observed trading patterns. With \(\hat{\boldsymbol{\theta}}\) in hand, any counterfactual cost function \(c'(\cdot; \boldsymbol{\theta}')\) can be fed into the model to predict the equilibrium response.
The distinction is not about sophistication (reduced-form work can be highly rigorous) but about the type of question being answered:
| Reduced Form | Structural | |
|---|---|---|
| Answers | “What happened?” | “What would happen if…?” |
| Identification | Exogenous variation (events, instruments) | Model restrictions + data moments |
| Key assumption | Unconfoundedness or exclusion restriction | Correct model specification |
| Output | Treatment effects, associations | Primitive parameters, counterfactuals |
| Risk | Omitted variable bias, weak instruments | Model misspecification |
43.1.2 Identification Through Economic Primitives
Structural identification relies on the economic model to convert observed outcomes into unobserved primitives. The classic example is the demand-supply system. Observing prices and quantities alone cannot identify demand and supply curves (the simultaneity problem). But if we know the functional form of demand and supply, and have instruments that shift one curve but not the other, the system is identified.
In financial contexts, identification often comes from the model’s equilibrium conditions. In Kyle (1985), the informed trader’s strategy, the market maker’s pricing rule, and the noise trader’s demand jointly determine the equilibrium price impact coefficient \(\lambda\). The model maps the observable (i.e., the price impact of order flow) to the unobservable (i.e., the precision of private information). The identification is through the model’s structure, not through a conventional instrument.
Formally, let \(\boldsymbol{\theta} \in \Theta\) denote the vector of structural parameters and \(\mathbf{m}(\boldsymbol{\theta})\) the model-implied moments. The data provide empirical moments \(\hat{\mathbf{m}}\). Identification requires that the mapping \(\boldsymbol{\theta} \mapsto \mathbf{m}(\boldsymbol{\theta})\) is injective: different parameter values produce different observable implications.
\[ \hat{\boldsymbol{\theta}} = \arg\min_{\boldsymbol{\theta} \in \Theta} \left[\hat{\mathbf{m}} - \mathbf{m}(\boldsymbol{\theta})\right]' \mathbf{W} \left[\hat{\mathbf{m}} - \mathbf{m}(\boldsymbol{\theta})\right] \tag{43.3}\]
This is the Generalized Method of Moments (GMM) framework applied to structural estimation. The choice of weighting matrix \(\mathbf{W}\) determines efficiency, and over-identifying restrictions (more moments than parameters) provide specification tests.
43.1.3 Tradeoffs Between Realism and Tractability
Every structural model involves choices about which features of reality to include and which to abstract from. Table 43.2 illustrates this for several canonical finance models.
| Model | Key Simplification | What It Misses | What It Gains |
|---|---|---|---|
| Kyle (1985) | Single informed trader, normal distributions | Multiple insiders, fat tails | Closed-form \(\lambda\), clean identification |
| Glosten and Milgrom (1985) | Binary signal, competitive market makers | Complex information, inventory costs | Bid-ask spread decomposition |
| Koijen and Yogo (2019) | Characteristics-based demand, no dynamics | Dynamic portfolio rebalancing | Demand elasticity estimation at scale |
| Roll (1984) | No information, serial independence | Information-driven trades | Spread estimation from return autocovariance |
The researcher’s task is to choose the simplest model that captures the economic mechanism of interest while remaining rich enough that its counterfactual predictions are credible. As Rust (1987) emphasized, there is a tension between models that are “structurally correct” but computationally intractable and models that are tractable but potentially misspecified.
43.2 Demand Estimation for Financial Assets
43.2.1 The Demand System Approach
Koijen and Yogo (2019) (hereafter KY) develop a demand system for financial assets that estimates the elasticity of institutional investor demand with respect to asset characteristics. The framework adapts the discrete-choice demand estimation methodology of Berry, Levinsohn, and Pakes (1995) from industrial organization to financial markets.
Each investor \(i\) holds a portfolio of \(N\) assets. The demand for asset \(j\) by investor \(i\) at time \(t\) is:
\[ w_{ij,t} = \frac{\exp(\delta_{j,t} + \boldsymbol{\beta}_i' \mathbf{x}_{j,t})}{1 + \sum_{k=1}^{N} \exp(\delta_{k,t} + \boldsymbol{\beta}_i' \mathbf{x}_{k,t})} \tag{43.4}\]
where \(w_{ij,t}\) is the portfolio weight of asset \(j\) for investor \(i\), \(\delta_{j,t}\) is the mean utility (common valuation), \(\mathbf{x}_{j,t}\) is a vector of observable characteristics (market cap, book-to-market, momentum, etc.), and \(\boldsymbol{\beta}_i\) captures investor-specific preferences (heterogeneous demand elasticities). The denominator includes 1 to represent the outside option (holding cash or non-equity assets).
The key insight is that the market-clearing condition links demand to equilibrium prices:
\[ \sum_i A_{i,t} \cdot w_{ij,t}(\boldsymbol{\theta}) = \text{ME}_{j,t} \qquad \forall j, t \tag{43.5}\]
where \(A_{i,t}\) is investor \(i\)’s total assets under management and \(\text{ME}_{j,t}\) is the market capitalization of asset \(j\). This system of equations determines the equilibrium price impacts and demand elasticities.
43.2.2 Demand Elasticity Estimation
The demand elasticity of asset \(j\) with respect to its own price (or a characteristic that moves its price) is:
\[ \varepsilon_{jj} = \frac{\partial \ln w_{ij}}{\partial \ln P_j} = \beta_{\text{price}} \cdot (1 - w_{ij}) \tag{43.6}\]
In a frictionless market with perfectly elastic demand, a supply shock (e.g., an index addition) would have zero price impact. Empirically, demand curves for financial assets slope downward with finite elasticity, implying that supply shocks move equilibrium prices.
# DataCore.vn API
from datacore import DataCore
dc = DataCore()
# Load institutional holdings data
holdings = dc.get_institutional_holdings(
start_date="2012-01-01",
end_date="2024-12-31"
)
# Load firm characteristics
firm_chars = dc.get_firm_characteristics(
start_date="2012-01-01",
end_date="2024-12-31"
)
# Load market data
market_data = dc.get_daily_returns(
start_date="2012-01-01",
end_date="2024-12-31"
)
print(f"Holdings observations: {len(holdings)}")
print(f"Unique institutions: {holdings['institution_id'].nunique()}")
print(f"Unique stocks: {holdings['ticker'].nunique()}")# Construct demand system variables
demand = holdings.merge(
firm_chars[["ticker", "date", "market_cap", "book_to_market",
"momentum_12m", "log_size", "profitability",
"investment", "industry"]],
on=["ticker", "date"],
how="inner"
)
# Portfolio weights
demand["total_aum"] = demand.groupby(
["institution_id", "date"]
)["holding_value"].transform("sum")
demand["port_weight"] = demand["holding_value"] / demand["total_aum"]
# Log portfolio weight relative to outside option
# w_ij / w_i0 where w_i0 = 1 - sum(w_ij) for equity holdings
demand["sum_equity_weight"] = demand.groupby(
["institution_id", "date"]
)["port_weight"].transform("sum")
demand["outside_weight"] = 1 - demand["sum_equity_weight"].clip(upper=0.99)
demand["log_weight_ratio"] = np.log(
demand["port_weight"] / demand["outside_weight"]
)# Simplified KY demand estimation
# log(w_ij / w_i0) = delta_j + beta_i' x_j + epsilon_ij
# First stage: estimate mean utility delta_j via OLS with asset FE
# Cross-sectional regression at each date
def estimate_demand_cross_section(group):
"""
Estimate demand parameters for a single cross-section.
Uses OLS with stock fixed effects absorbed.
"""
g = group.dropna(subset=[
"log_weight_ratio", "log_size", "book_to_market",
"momentum_12m", "profitability"
])
if len(g) < 50:
return pd.Series(dtype=float)
X = g[["log_size", "book_to_market", "momentum_12m",
"profitability"]].values
X = sm.add_constant(X)
y = g["log_weight_ratio"].values
try:
model = sm.OLS(y, X).fit()
return pd.Series({
"beta_size": model.params[1],
"beta_btm": model.params[2],
"beta_mom": model.params[3],
"beta_prof": model.params[4],
"r_squared": model.rsquared,
"n_obs": len(g)
})
except Exception:
return pd.Series(dtype=float)
# Estimate by institution type
demand["institution_type"] = demand["institution_type"].fillna("Other")
demand_params = (
demand.groupby(["institution_type", "date"])
.apply(estimate_demand_cross_section)
.reset_index()
.dropna()
)avg_params = (
demand_params.groupby("institution_type")
.agg(
beta_size=("beta_size", "mean"),
beta_btm=("beta_btm", "mean"),
beta_mom=("beta_mom", "mean"),
beta_prof=("beta_prof", "mean"),
avg_r2=("r_squared", "mean"),
n_periods=("date", "nunique")
)
.round(4)
)
avg_paramstop_types = demand_params.groupby("institution_type").size().nlargest(4).index
plot_data = demand_params[
demand_params["institution_type"].isin(top_types)
].copy()
(
p9.ggplot(plot_data, p9.aes(
x="date", y="beta_size", color="institution_type"
))
+ p9.geom_smooth(method="lowess", se=False, size=1)
+ p9.labs(
x="", y="β (Size)",
title="Institutional Demand Sensitivity to Firm Size",
color="Institution Type"
)
+ p9.theme_minimal()
+ p9.theme(figure_size=(12, 5), legend_position="top")
)43.2.3 Price Pressure and Market Impact
The demand system framework directly yields price impact predictions. If investor \(i\) receives an exogenous inflow \(\Delta A_i\) (e.g., from a fund flow shock), the price of each stock must adjust to clear the market with the new demand. The equilibrium price impact of a demand shock is:
\[ \frac{\Delta P_j}{P_j} = \frac{1}{\varepsilon_{jj}} \cdot \frac{\Delta D_j}{D_j} \tag{43.7}\]
where \(\varepsilon_{jj}\) is the demand elasticity and \(\Delta D_j / D_j\) is the percentage demand shock. Less elastic demand implies larger price impact for the same demand shock (a prediction with direct implications for the price impact of index rebalancing, fund flows, and forced selling).
# Estimate aggregate demand elasticity via index rebalancing events
# Use VN30 index reconstitutions as demand shocks
index_changes = dc.get_index_changes(
index="VN30",
start_date="2012-01-01",
end_date="2024-12-31"
)
# Event study: abnormal returns around index additions/deletions
def event_study_car(events, returns, window=(-5, 20)):
"""
Compute cumulative abnormal returns around events.
Parameters
----------
events : DataFrame
Columns: ticker, event_date, event_type (addition/deletion).
returns : DataFrame
Columns: ticker, date, ret, mkt_ret.
Returns
-------
DataFrame : CARs by event type and event day.
"""
results = []
for _, event in events.iterrows():
ticker = event["ticker"]
event_date = pd.to_datetime(event["event_date"])
# Estimation window: -260 to -11
stock_rets = returns[returns["ticker"] == ticker].copy()
stock_rets = stock_rets.sort_values("date")
est_mask = (
(stock_rets["date"] >= event_date - pd.Timedelta(days=365)) &
(stock_rets["date"] < event_date - pd.Timedelta(days=15))
)
est_data = stock_rets[est_mask].dropna(subset=["ret", "mkt_ret"])
if len(est_data) < 60:
continue
# Market model
model = sm.OLS(
est_data["ret"],
sm.add_constant(est_data["mkt_ret"])
).fit()
# Event window
evt_mask = (
(stock_rets["date"] >= event_date + pd.Timedelta(days=window[0] * 1.5)) &
(stock_rets["date"] <= event_date + pd.Timedelta(days=window[1] * 1.5))
)
evt_data = stock_rets[evt_mask].dropna(subset=["ret", "mkt_ret"])
if len(evt_data) < 5:
continue
# Abnormal returns
evt_data = evt_data.copy()
evt_data["expected"] = model.predict(
sm.add_constant(evt_data["mkt_ret"])
)
evt_data["ar"] = evt_data["ret"] - evt_data["expected"]
# Assign event-time index
trading_dates = evt_data["date"].sort_values().values
event_idx = np.searchsorted(trading_dates, event_date)
evt_data["event_day"] = range(-event_idx, len(evt_data) - event_idx)
evt_data["event_type"] = event["event_type"]
evt_data["ticker"] = ticker
results.append(evt_data[["ticker", "event_day", "ar", "event_type"]])
if not results:
return pd.DataFrame()
return pd.concat(results, ignore_index=True)
# Compute event study
daily_data = market_data.merge(
dc.get_market_returns(
start_date="2012-01-01",
end_date="2024-12-31",
frequency="daily"
)[["date", "mkt_ret"]],
on="date", how="left"
)
car_results = event_study_car(index_changes, daily_data)if len(car_results) > 0:
# Average AR by event day and type
avg_ar = (
car_results.groupby(["event_type", "event_day"])
.agg(
mean_ar=("ar", "mean"),
se_ar=("ar", lambda x: x.std() / np.sqrt(len(x))),
n=("ar", "count")
)
.reset_index()
)
# Cumulative AR
for etype in avg_ar["event_type"].unique():
mask = avg_ar["event_type"] == etype
avg_ar.loc[mask, "car"] = avg_ar.loc[mask, "mean_ar"].cumsum()
avg_ar = avg_ar[avg_ar["event_day"].between(-5, 20)]
(
p9.ggplot(avg_ar, p9.aes(
x="event_day", y="car", color="event_type"
))
+ p9.geom_line(size=1)
+ p9.geom_vline(xintercept=0, linetype="dashed", color="gray")
+ p9.geom_hline(yintercept=0, linetype="dotted", color="gray")
+ p9.scale_color_manual(values=["#27AE60", "#C0392B"])
+ p9.labs(
x="Event Day",
y="Cumulative Abnormal Return",
title="Price Pressure from VN30 Index Additions and Deletions",
color="Event"
)
+ p9.theme_minimal()
+ p9.theme(figure_size=(10, 6))
)The permanent component of the CAR measures the information content of index inclusion, while the temporary component (reversal after the event) measures pure price pressure from demand. In a world with perfectly elastic demand, there would be no temporary price impact. The magnitude of the temporary component is inversely related to the demand elasticity, providing a second identification strategy (complementary to the holdings-based approach) for demand curves.
43.2.4 Demand Inelasticity and Its Implications
Gabaix and Koijen (2021) argue that the aggregate demand for equities is remarkably inelastic, with elasticity estimates on the order of 0.2 (meaning a 1% supply increase requires a 5% price decline to clear). The implications are profound: if demand is this inelastic, then flows (from index funds, foreign investors, retail traders) have outsized effects on prices. In Vietnamese markets, where foreign ownership caps create binding constraints on a key investor class, demand inelasticity may be even more severe.
The inelasticity also generates a role for “the market portfolio” as an equilibrium concept: if most investors hold portfolios close to the market (as in CAPM), then deviations from market weights require someone to absorb the excess supply, and the compensation required is the inverse of demand elasticity.
43.3 Structural Models of Trading Strategies
43.3.1 Optimal Execution: The Almgren-Chriss Framework
The optimal execution problem, formalized by Almgren and Chriss (2001), asks: given a large order to execute over a fixed horizon, what is the optimal trading schedule that minimizes the total execution cost? The problem is fundamental to institutional investing because large orders cannot be executed instantaneously without severe price impact.
Let \(X_t\) denote the remaining shares to sell at time \(t\), with \(X_0 = X\) (total order) and \(X_T = 0\) (completion). The trading rate is \(n_t = X_{t-1} - X_t\). The execution price for shares traded at \(t\) is affected by both temporary and permanent price impact:
\[ S_t = S_0 + \sigma W_t - g(n_t) - h\left(\frac{n_t}{\tau}\right) \tag{43.8}\]
where \(g(\cdot)\) is the permanent impact function, \(h(\cdot)\) is the temporary impact function, \(\sigma\) is the volatility, \(W_t\) is a Wiener process, and \(\tau\) is the time interval. The total implementation shortfall (cost of execution relative to the arrival price \(S_0\)) is:
\[ \text{IS} = \sum_{t=1}^{T} n_t (S_0 - S_t) = \sum_{t=1}^{T} n_t \left[\sigma W_t + g(n_t) + h\left(\frac{n_t}{\tau}\right)\right] \tag{43.9}\]
The trader minimizes a mean-variance objective:
\[ \min_{\{n_t\}} \; E[\text{IS}] + \lambda \cdot \text{Var}(\text{IS}) \tag{43.10}\]
where \(\lambda\) is the risk aversion parameter. With linear impact functions \(g(n) = \gamma n\) and \(h(v) = \eta v + \epsilon \text{sgn}(v)\), the optimal strategy has a closed-form solution.
TWAP (Time-Weighted Average Price): Trade uniformly: \(n_t = X / T\) for all \(t\). Optimal when permanent impact dominates temporary impact.
VWAP (Volume-Weighted Average Price): Trade proportionally to expected volume: \(n_t \propto \hat{V}_t\). Approximates TWAP adjusted for intraday volume patterns.
Almgren-Chriss Optimal: The risk-averse optimal trajectory for linear impact is:
\[ x_t^* = X \cdot \frac{\sinh(\kappa (T - t))}{\sinh(\kappa T)} \tag{43.11}\]
where \(\kappa = \sqrt{\lambda \sigma^2 / \eta}\) governs the trade-off between urgency (trading quickly to reduce risk) and patience (trading slowly to reduce impact). High risk aversion (\(\lambda\) large) implies front-loaded execution.
def almgren_chriss_trajectory(X, T, sigma, eta, gamma, lam, n_steps=100):
"""
Compute Almgren-Chriss optimal execution trajectory.
Parameters
----------
X : float
Total shares to execute.
T : float
Execution horizon (in trading days).
sigma : float
Daily return volatility.
eta : float
Temporary impact parameter.
gamma : float
Permanent impact parameter.
lam : float
Risk aversion parameter.
n_steps : int
Number of time steps.
Returns
-------
DataFrame : Time, remaining shares, trading rate, expected cost.
"""
tau = T / n_steps
kappa_sq = lam * sigma**2 / (eta / tau)
if kappa_sq <= 0:
# Risk-neutral: trade uniformly
kappa = 0
x_t = np.linspace(X, 0, n_steps + 1)
else:
kappa = np.sqrt(kappa_sq)
t_grid = np.linspace(0, T, n_steps + 1)
x_t = X * np.sinh(kappa * (T - t_grid)) / np.sinh(kappa * T)
# Trading rates
n_t = -np.diff(x_t)
# Expected cost components
perm_cost = gamma * np.sum(n_t**2)
temp_cost = (eta / tau) * np.sum(n_t**2)
total_expected_cost = perm_cost + temp_cost
# Variance of cost
var_cost = sigma**2 * tau * np.sum(x_t[:-1]**2)
results = pd.DataFrame({
"time": np.linspace(0, T, n_steps + 1)[:-1],
"remaining_shares": x_t[:-1],
"trade_rate": n_t
})
results.attrs["expected_cost"] = total_expected_cost
results.attrs["cost_variance"] = var_cost
results.attrs["kappa"] = kappa if kappa_sq > 0 else 0
return results# Estimate market impact parameters from Vietnamese data
# Typical values for mid-cap Vietnamese stocks
sigma_daily = 0.025 # 2.5% daily vol
eta = 2.5e-7 # Temporary impact
gamma = 1.0e-7 # Permanent impact
X_shares = 500000 # 500k shares to sell
T_days = 5 # 5-day horizon
trajectories = []
for lam, label in [(0, "Risk Neutral (TWAP)"),
(1e-6, "Low Risk Aversion"),
(1e-5, "Medium Risk Aversion"),
(1e-4, "High Risk Aversion")]:
traj = almgren_chriss_trajectory(
X_shares, T_days, sigma_daily, eta, gamma, lam
)
traj["strategy"] = label
traj["pct_remaining"] = traj["remaining_shares"] / X_shares * 100
trajectories.append(traj)
traj_all = pd.concat(trajectories, ignore_index=True)
(
p9.ggplot(traj_all, p9.aes(
x="time", y="pct_remaining", color="strategy"
))
+ p9.geom_line(size=1)
+ p9.scale_color_manual(
values=["#95A5A6", "#27AE60", "#2E5090", "#C0392B"]
)
+ p9.labs(
x="Time (Trading Days)",
y="Remaining Order (%)",
title="Almgren-Chriss Optimal Execution Trajectories",
color="Strategy"
)
+ p9.theme_minimal()
+ p9.theme(figure_size=(10, 6), legend_position="top")
)43.3.2 Estimating Market Impact from Transaction Data
The structural parameters \((\gamma, \eta, \sigma)\) must be estimated from data. The standard approach uses the square-root law of market impact, documented empirically by Kyle (1985) and Hasbrouck (1991) and derived theoretically by Gabaix et al. (2003):
\[ \Delta P / P = c \cdot \text{sgn}(Q) \cdot |Q / V|^{\delta} \tag{43.12}\]
where \(Q\) is the signed order flow, \(V\) is daily volume, \(c\) is the impact coefficient, and \(\delta \approx 0.5\) is the impact exponent. The “square root” refers to \(\delta = 0.5\), which is remarkably robust across markets, time periods, and asset classes.
# Load intraday transaction data (or daily order flow proxy)
order_flow = dc.get_order_flow(
start_date="2018-01-01",
end_date="2024-12-31",
frequency="daily"
)
# Construct signed order flow using Lee-Ready classification
# (buy-initiated minus sell-initiated volume)
impact_data = order_flow.merge(
dc.get_daily_returns(
start_date="2018-01-01",
end_date="2024-12-31"
)[["ticker", "date", "ret", "volume", "market_cap"]],
on=["ticker", "date"],
how="inner"
)
# Normalize order flow
impact_data["oib"] = impact_data["net_buy_volume"] / impact_data["volume"]
impact_data["abs_oib"] = np.abs(impact_data["oib"])
impact_data["sign_oib"] = np.sign(impact_data["oib"])
# Log-log regression: ln|ΔP| = α + δ ln|Q/V| + ε
impact_data["ln_abs_ret"] = np.log(np.abs(impact_data["ret"]).clip(lower=1e-8))
impact_data["ln_abs_oib"] = np.log(impact_data["abs_oib"].clip(lower=1e-8))
# Estimate by size quintile
impact_data["size_quintile"] = impact_data.groupby("date")[
"market_cap"
].transform(lambda x: pd.qcut(x, 5, labels=[1, 2, 3, 4, 5],
duplicates="drop"))
impact_results = []
for q in range(1, 6):
subset = impact_data[impact_data["size_quintile"] == q].dropna(
subset=["ln_abs_ret", "ln_abs_oib"]
)
if len(subset) < 500:
continue
model = sm.OLS(
subset["ln_abs_ret"],
sm.add_constant(subset["ln_abs_oib"])
).fit(cov_type="HC1")
impact_results.append({
"size_quintile": q,
"delta_hat": model.params.iloc[1],
"delta_se": model.bse.iloc[1],
"intercept": model.params.iloc[0],
"r_squared": model.rsquared,
"n_obs": int(model.nobs)
})
impact_df = pd.DataFrame(impact_results)impact_df.round(4)The impact exponent \(\hat{\delta}\) near 0.5 across size quintiles confirms the universality of the square-root law. Smaller firms typically exhibit higher impact coefficients (larger \(c\)), consistent with lower liquidity.
43.3.3 Transaction Cost Decomposition
Total transaction costs comprise multiple components, each with distinct economic content:
\[ \text{TC} = \underbrace{\frac{s}{2}}_{\text{Half-spread}} + \underbrace{\gamma \cdot Q}_{\text{Permanent impact}} + \underbrace{\eta \cdot \frac{Q}{V}}_{\text{Temporary impact}} + \underbrace{\sigma \sqrt{T}}_{\text{Timing risk}} \tag{43.13}\]
We estimate each component separately, following the Hasbrouck (2009) methodology for effective spread decomposition.
# Effective spread estimation
spreads = dc.get_bid_ask_data(
start_date="2020-01-01",
end_date="2024-12-31"
)
# Quoted spread
spreads["quoted_spread"] = (
(spreads["ask"] - spreads["bid"]) / spreads["midpoint"]
)
# Effective spread (from transaction prices)
spreads["effective_spread"] = (
2 * np.abs(spreads["trade_price"] - spreads["midpoint"]) /
spreads["midpoint"]
)
# Roll (1984) implied spread from return autocovariance
def roll_spread(returns):
"""
Estimate the Roll (1984) bid-ask spread.
Spread = 2 * sqrt(-Cov(r_t, r_{t-1})) if covariance is negative.
"""
cov = np.cov(returns[1:], returns[:-1])[0, 1]
if cov < 0:
return 2 * np.sqrt(-cov)
else:
return np.nan
# Compute by stock-month
daily_rets = dc.get_daily_returns(
start_date="2020-01-01",
end_date="2024-12-31"
)
daily_rets["month"] = daily_rets["date"].dt.to_period("M")
roll_spreads = (
daily_rets.groupby(["ticker", "month"])
.agg(
roll_spread=("ret", lambda x: roll_spread(x.values)
if len(x) > 10 else np.nan),
n_days=("ret", "count"),
avg_volume=("volume", "mean"),
market_cap=("market_cap", "last")
)
.reset_index()
.dropna(subset=["roll_spread"])
)# Bin by market cap deciles
roll_spreads["size_decile"] = roll_spreads.groupby("month")[
"market_cap"
].transform(lambda x: pd.qcut(x, 10, labels=range(1, 11),
duplicates="drop"))
spread_by_size = (
roll_spreads.groupby("size_decile")
.agg(
median_spread=("roll_spread", "median"),
mean_spread=("roll_spread", "mean"),
q25=("roll_spread", lambda x: x.quantile(0.25)),
q75=("roll_spread", lambda x: x.quantile(0.75))
)
.reset_index()
)
(
p9.ggplot(spread_by_size, p9.aes(x="size_decile", y="median_spread"))
+ p9.geom_bar(stat="identity", fill="#2E5090", alpha=0.7)
+ p9.geom_errorbar(
p9.aes(ymin="q25", ymax="q75"),
width=0.3, color="#2E5090"
)
+ p9.labs(
x="Size Decile (1 = Smallest)",
y="Roll Implied Spread",
title="Transaction Costs Decrease Monotonically with Firm Size"
)
+ p9.scale_y_continuous(labels=percent_format())
+ p9.theme_minimal()
+ p9.theme(figure_size=(10, 5))
)43.4 Auction Models in Primary Markets
43.4.1 The IPO Allocation Problem
Initial public offerings present a classic information asymmetry problem. Issuers and underwriters do not know the true market-clearing price; informed investors do (approximately). The allocation mechanism (i.e., how shares are distributed and at what price) determines the IPO’s pricing efficiency, the degree of underpricing, and the distribution of surplus between issuers and investors.
Rock (1986) provides the canonical model. There are two types of investors: informed (who know the true value \(v\)) and uninformed (who know only the distribution \(v \sim F\)). If the IPO is priced at \(P\):
- When \(v > P\) (good IPO): both informed and uninformed subscribe, so uninformed receive only a fraction of their order (rationing).
- When \(v < P\) (bad IPO): only uninformed subscribe, so they receive full allocation (the “winner’s curse”).
The uninformed investor’s expected return, accounting for rationing, is:
\[ E[R_{\text{uninformed}}] = \alpha_g \cdot E\left[\frac{v - P}{P} \mid v > P\right] \cdot \Pr(v > P) + E\left[\frac{v - P}{P} \mid v \leq P\right] \cdot \Pr(v \leq P) \tag{43.14}\]
where \(\alpha_g < 1\) is the allocation probability in good IPOs (rationed) and allocation is 1 in bad IPOs. For uninformed investors to participate, \(E[R_{\text{uninformed}}] \geq 0\), which requires:
\[ E\left[\frac{v - P}{P}\right] > 0 \tag{43.15}\]
That is, IPOs must be underpriced on average to compensate uninformed investors for the winner’s curse. The degree of required underpricing increases with the proportion of informed investors and the variance of the true value.
43.4.2 Book Building vs. Auction Mechanisms
Vietnamese IPO history provides variation in allocation mechanisms. State-owned enterprise equitizations have used Dutch auctions, while private-sector IPOs have used book building. This institutional variation allows structural comparison of the mechanisms.
Book Building (Benveniste and Spindt 1989): The underwriter solicits indications of interest from institutional investors during the roadshow. Investors who reveal positive information (high valuations) receive favorable allocations as compensation. The mechanism aggregates information efficiently but grants discretion to the underwriter.
Uniform-Price Auction: All winning bidders pay the same market-clearing price. This eliminates the underwriter’s allocation discretion but may lead to free-riding on information revelation.
# Load IPO data
ipo_data = dc.get_ipo_data(
start_date="2005-01-01",
end_date="2024-12-31"
)
# Compute first-day returns (underpricing)
ipo_data["underpricing"] = (
(ipo_data["first_day_close"] - ipo_data["offer_price"]) /
ipo_data["offer_price"]
)
# Classify mechanism
ipo_data["mechanism"] = ipo_data["ipo_method"].map({
"auction": "Auction",
"book_building": "Book Building",
"fixed_price": "Fixed Price"
})
print(f"Total IPOs: {len(ipo_data)}")
print(f"By mechanism:\n{ipo_data['mechanism'].value_counts()}")ipo_summary = (
ipo_data.groupby("mechanism")
.agg(
n_ipos=("underpricing", "count"),
mean_underpricing=("underpricing", "mean"),
median_underpricing=("underpricing", "median"),
std_underpricing=("underpricing", "std"),
pct_positive=("underpricing", lambda x: (x > 0).mean()),
mean_proceeds=("proceeds_bn_vnd", "mean")
)
.round(4)
)
ipo_summary43.4.3 Structural Estimation of Information Asymmetry
We estimate the Rock (1986) model parameters (i.e., the fraction of informed investors (\(\mu\)) and the precision of their information (\(\sigma_v^2\))) using the method of simulated moments (MSM).
The model predicts two key moments:
1 . Average underpricing: \(E[U] = f(\mu, \sigma_v^2)\) 2. Cross-sectional variance of underpricing: \(\text{Var}(U) = g(\mu, \sigma_v^2)\)
We match these model-implied moments to the data.
def rock_model_moments(params, n_sim=10000):
"""
Simulate Rock (1986) IPO model and compute moments.
Parameters
----------
params : tuple
(mu, sigma_v) - fraction of informed investors,
std dev of true value.
Returns
-------
tuple : (mean underpricing, variance of underpricing).
"""
mu, sigma_v = params
np.random.seed(42)
# True values
v = np.random.lognormal(mean=0, sigma=sigma_v, size=n_sim)
# Offer price: set to break even for uninformed
# Simplified: P = E[v] * discount_factor
P = np.exp(sigma_v**2 / 2) * 0.9 # 10% average discount
# First-day returns
returns = (v - P) / P
# Allocation probability for uninformed in good IPOs
# Depends on mu: more informed -> more rationing
good_mask = v > P
alpha_g = (1 - mu) / 1.0 # Simplified rationing
# Uninformed realized returns
uninformed_returns = np.where(
good_mask,
alpha_g * returns,
returns
)
mean_u = uninformed_returns.mean()
var_u = uninformed_returns.var()
return mean_u, var_u
def rock_model_objective(params, target_moments, weight_matrix=None):
"""
GMM objective for Rock model estimation.
"""
mu, sigma_v = params
if mu <= 0 or mu >= 1 or sigma_v <= 0 or sigma_v > 2:
return 1e10
model_moments = rock_model_moments(params)
diff = np.array(model_moments) - np.array(target_moments)
if weight_matrix is None:
weight_matrix = np.eye(len(diff))
return diff @ weight_matrix @ diff
# Target moments from data
target_mean = ipo_data["underpricing"].mean()
target_var = ipo_data["underpricing"].var()
# Estimate
result = minimize(
rock_model_objective,
x0=[0.3, 0.5],
args=([target_mean, target_var],),
method="Nelder-Mead",
options={"maxiter": 5000}
)
mu_hat, sigma_v_hat = result.x
print(f"Rock Model Estimates:")
print(f" Fraction informed (μ): {mu_hat:.4f}")
print(f" Value uncertainty (σ_v): {sigma_v_hat:.4f}")
print(f" Objective value: {result.fun:.6f}")ipo_plot = ipo_data.dropna(subset=["underpricing", "mechanism"]).copy()
ipo_plot["year"] = pd.to_datetime(ipo_plot["ipo_date"]).dt.year
annual_underpricing = (
ipo_plot.groupby(["year", "mechanism"])
.agg(
mean_up=("underpricing", "mean"),
n=("underpricing", "count")
)
.reset_index()
.query("n >= 3")
)
(
p9.ggplot(annual_underpricing, p9.aes(
x="year", y="mean_up", color="mechanism"
))
+ p9.geom_line(size=1)
+ p9.geom_point(p9.aes(size="n"))
+ p9.geom_hline(yintercept=0, linetype="dashed", color="gray")
+ p9.scale_color_manual(values=["#2E5090", "#C0392B", "#27AE60"])
+ p9.scale_y_continuous(labels=percent_format())
+ p9.labs(
x="Year",
y="Average First-Day Return",
title="IPO Underpricing by Allocation Mechanism",
color="Mechanism", size="# IPOs"
)
+ p9.theme_minimal()
+ p9.theme(figure_size=(12, 6))
)43.4.4 Counterfactual: Welfare Under Alternative Mechanisms
With the structural parameters \((\hat{\mu}, \hat{\sigma}_v)\) in hand, we can simulate counterfactual outcomes. For instance: if all Vietnamese IPOs used uniform-price auctions instead of book building, how would underpricing and welfare change?
def simulate_ipo_mechanism(mu, sigma_v, mechanism="book_building",
n_sim=50000):
"""
Simulate IPO outcomes under different mechanisms.
Returns issuer surplus, informed profit, uninformed profit.
"""
np.random.seed(42)
v = np.random.lognormal(mean=0, sigma=sigma_v, size=n_sim)
if mechanism == "book_building":
# Price partially reveals information
# P = E[v] + 0.5 * (v - E[v]) * signal_quality
signal = v + np.random.normal(0, sigma_v * 0.5, n_sim)
P = np.exp(sigma_v**2 / 2) + 0.3 * (signal - np.exp(sigma_v**2 / 2))
P = P.clip(min=0.1)
elif mechanism == "auction":
# Competitive bidding: P closer to v
noise = np.random.normal(0, sigma_v * 0.3, n_sim)
P = v + noise
P = P.clip(min=0.1)
elif mechanism == "fixed_price":
# Fixed at E[v] * discount
P = np.full(n_sim, np.exp(sigma_v**2 / 2) * 0.85)
underpricing = (v - P) / P
issuer_surplus = P # Revenue per share
investor_surplus = v - P # Profit per share
return {
"mechanism": mechanism,
"mean_underpricing": underpricing.mean(),
"median_underpricing": np.median(underpricing),
"std_underpricing": underpricing.std(),
"issuer_revenue": issuer_surplus.mean(),
"investor_profit": investor_surplus.mean(),
"money_left": (investor_surplus[investor_surplus > 0]).sum() / n_sim
}
# Compare mechanisms
counterfactual = pd.DataFrame([
simulate_ipo_mechanism(mu_hat, sigma_v_hat, "book_building"),
simulate_ipo_mechanism(mu_hat, sigma_v_hat, "auction"),
simulate_ipo_mechanism(mu_hat, sigma_v_hat, "fixed_price")
]).set_index("mechanism").round(4)
counterfactual43.5 Limit Order Book Models
43.5.1 Order Submission as a Strategic Choice
In a limit order market, every trader faces a fundamental tradeoff: submit a market order (immediate execution, but at an adverse price) or a limit order (better price, but risk of non-execution). Parlour (1998) models this as a sequential game where each trader’s optimal strategy depends on the current state of the order book.
Let \(a_t\) and \(b_t\) denote the best ask and bid prices at time \(t\), with the spread \(s_t = a_t - b_t\). A buyer who arrives at time \(t\) chooses between:
- Market buy: Execute immediately at \(a_t\). Cost: \(a_t - v_t\) where \(v_t\) is the true value.
- Limit buy at \(b_t\): Provides liquidity. If executed, profit: \(v_t - b_t\). Probability of execution: \(\pi(b_t, \text{book state})\).
The buyer submits a market order if:
\[ a_t - v_t < (1 - \pi_t)(v_t - b_t) + \pi_t \cdot 0 \tag{43.16}\]
Rearranging: market orders are optimal when the spread is narrow relative to the non-execution risk of limit orders. This generates the empirically observed pattern that limit orders are more attractive when spreads are wide and the book is thin.
43.5.2 The Glosten-Milgrom Model
Glosten and Milgrom (1985) provide the foundational structural model of bid-ask spread determination under asymmetric information. A competitive market maker sets bid and ask prices to break even in expectation, recognizing that some trades come from informed traders.
Let \(\mu\) denote the probability that an incoming order is from an informed trader, and let \(V^H\) and \(V^L\) denote the high and low values of the asset (\(\Pr(V = V^H) = p\)). The zero-profit conditions yield:
\[ a = E[V \mid \text{buy order}] = \frac{p(1-\mu) + p\mu}{(1-\mu) + p\mu} V^H + \frac{(1-p)(1-\mu)}{(1-\mu) + p\mu} V^L \tag{43.17}\]
\[ b = E[V \mid \text{sell order}] = \frac{p(1-\mu)}{(1-\mu) + (1-p)\mu} V^H + \frac{(1-p)(1-\mu) + (1-p)\mu}{(1-\mu) + (1-p)\mu} V^L \tag{43.18}\]
The spread \(s = a - b\) is positive whenever \(\mu > 0\), and increases in both the proportion of informed traders (\(\mu\)) and the information asymmetry (\(V^H - V^L\)). This decomposition is foundational: the spread compensates liquidity providers for the adverse selection cost of trading with informed counterparties.
43.5.3 PIN: Probability of Informed Trading
Easley et al. (1996) extend the Glosten-Milgrom framework to a dynamic setting and develop the Probability of Informed Trading (PIN) measure, which can be estimated from trade data. The model assumes that on each trading day, an information event occurs with probability \(\alpha\). Conditional on an event, it is bad news with probability \(\delta\). Informed traders arrive at rate \(\mu\); uninformed buyers and sellers arrive at rates \(\varepsilon_b\) and \(\varepsilon_s\), respectively.
The likelihood of observing \(B_t\) buys and \(S_t\) sells on day \(t\) is:
\[ \mathcal{L}(B_t, S_t | \boldsymbol{\theta}) = (1-\alpha) f(B_t|\varepsilon_b) f(S_t|\varepsilon_s) + \alpha\delta \cdot f(B_t|\varepsilon_b) f(S_t|\varepsilon_s + \mu) + \alpha(1-\delta) \cdot f(B_t|\varepsilon_b + \mu) f(S_t|\varepsilon_s) \tag{43.19}\]
where \(f(\cdot|\lambda)\) is the Poisson density with rate \(\lambda\), and \(\boldsymbol{\theta} = (\alpha, \delta, \mu, \varepsilon_b, \varepsilon_s)\). PIN is then:
\[ \text{PIN} = \frac{\alpha \mu}{\alpha \mu + \varepsilon_b + \varepsilon_s} \tag{43.20}\]
def pin_log_likelihood(params, data):
"""
Compute negative log-likelihood for the Easley-Kiefer-O'Hara-Paperman
(1996) PIN model.
Parameters
----------
params : array
[alpha, delta, mu, eps_b, eps_s]
data : DataFrame
Columns: buys, sells (daily counts).
Returns
-------
float : Negative log-likelihood.
"""
alpha, delta, mu, eps_b, eps_s = params
# Parameter bounds
if (alpha < 0 or alpha > 1 or delta < 0 or delta > 1 or
mu < 0 or eps_b < 0 or eps_s < 0):
return 1e15
B = data["buys"].values
S = data["sells"].values
# Use log-sum-exp for numerical stability
# Three components: no event, bad news, good news
log_L = np.zeros(len(B))
for i in range(len(B)):
b, s = B[i], S[i]
# Log-Poisson components
def log_poisson(k, lam):
if lam <= 0:
return -1e10 if k > 0 else 0
return k * np.log(lam) - lam - np.sum(np.log(np.arange(1, k + 1)))
# No event
c1 = (np.log(1 - alpha + 1e-15) +
log_poisson(b, eps_b) + log_poisson(s, eps_s))
# Bad news (extra sells)
c2 = (np.log(alpha * delta + 1e-15) +
log_poisson(b, eps_b) + log_poisson(s, eps_s + mu))
# Good news (extra buys)
c3 = (np.log(alpha * (1 - delta) + 1e-15) +
log_poisson(b, eps_b + mu) + log_poisson(s, eps_s))
# Log-sum-exp
max_c = max(c1, c2, c3)
log_L[i] = max_c + np.log(
np.exp(c1 - max_c) + np.exp(c2 - max_c) + np.exp(c3 - max_c)
)
return -np.sum(log_L)
def estimate_pin(buys, sells, n_starts=10):
"""
Estimate PIN via MLE with multiple random starts.
Returns
-------
dict : Estimated parameters and PIN value.
"""
data = pd.DataFrame({"buys": buys, "sells": sells})
best_result = None
best_nll = np.inf
avg_b = data["buys"].mean()
avg_s = data["sells"].mean()
for _ in range(n_starts):
# Random initial values
alpha0 = np.random.uniform(0.1, 0.8)
delta0 = np.random.uniform(0.2, 0.8)
mu0 = np.random.uniform(0, max(avg_b, avg_s))
eps_b0 = avg_b * np.random.uniform(0.5, 1.5)
eps_s0 = avg_s * np.random.uniform(0.5, 1.5)
try:
result = minimize(
pin_log_likelihood,
x0=[alpha0, delta0, mu0, eps_b0, eps_s0],
args=(data,),
method="L-BFGS-B",
bounds=[(0.01, 0.99), (0.01, 0.99),
(0.01, None), (0.01, None), (0.01, None)],
options={"maxiter": 1000}
)
if result.fun < best_nll:
best_nll = result.fun
best_result = result
except Exception:
continue
if best_result is None:
return {"pin": np.nan}
alpha, delta, mu, eps_b, eps_s = best_result.x
pin = alpha * mu / (alpha * mu + eps_b + eps_s)
return {
"alpha": alpha,
"delta": delta,
"mu": mu,
"eps_b": eps_b,
"eps_s": eps_s,
"pin": pin,
"log_likelihood": -best_nll
}# Estimate PIN for a cross-section of stocks
# Using daily buy/sell counts from Lee-Ready classification
trade_counts = dc.get_trade_counts(
start_date="2023-01-01",
end_date="2023-12-31"
)
# Sample of stocks for estimation (PIN is computationally intensive)
top_stocks = (
trade_counts.groupby("ticker")
.agg(n_days=("date", "nunique"))
.query("n_days >= 200")
.index[:50]
)
pin_estimates = []
for ticker in top_stocks:
stock_data = trade_counts[trade_counts["ticker"] == ticker]
result = estimate_pin(
stock_data["buys"].values,
stock_data["sells"].values,
n_starts=5
)
result["ticker"] = ticker
pin_estimates.append(result)
pin_df = pd.DataFrame(pin_estimates).dropna(subset=["pin"])
print(f"PIN estimates for {len(pin_df)} stocks")
print(f" Mean PIN: {pin_df['pin'].mean():.4f}")
print(f" Median PIN: {pin_df['pin'].median():.4f}")(
p9.ggplot(pin_df, p9.aes(x="pin"))
+ p9.geom_histogram(bins=25, fill="#2E5090", alpha=0.7)
+ p9.geom_vline(
xintercept=pin_df["pin"].median(),
linetype="dashed", color="#C0392B", size=0.8
)
+ p9.labs(
x="PIN",
y="Count",
title="Distribution of Informed Trading Probability"
)
+ p9.theme_minimal()
+ p9.theme(figure_size=(10, 5))
)43.5.4 PIN and Asset Pricing
Easley, Hvidkjaer, and O’hara (2002) argue that PIN should be priced in the cross-section of expected returns: stocks with higher information asymmetry require a risk premium to compensate uninformed investors. We test this prediction using Fama-MacBeth regressions.
# Merge PIN with firm characteristics and forward returns
pin_chars = pin_df[["ticker", "pin"]].merge(
dc.get_firm_characteristics(
start_date="2023-01-01",
end_date="2023-12-31"
).groupby("ticker").last().reset_index()[
["ticker", "log_size", "book_to_market", "momentum_12m"]
],
on="ticker",
how="inner"
)
# Forward 12-month returns (2024)
forward_returns = dc.get_annual_returns(
start_date="2024-01-01",
end_date="2024-12-31"
)
pin_returns = pin_chars.merge(
forward_returns[["ticker", "annual_ret"]],
on="ticker",
how="inner"
)
# Cross-sectional regression
if len(pin_returns) > 20:
cs_model = sm.OLS(
pin_returns["annual_ret"],
sm.add_constant(
pin_returns[["pin", "log_size", "book_to_market", "momentum_12m"]]
)
).fit(cov_type="HC1")
print("Cross-Sectional Return Regression with PIN:")
for var in ["pin", "log_size", "book_to_market", "momentum_12m"]:
print(f" {var}: {cs_model.params[var]:.4f} "
f"(t = {cs_model.tvalues[var]:.3f})")43.5.5 Estimation Challenges in Limit Order Book Models
Structural estimation of limit order book models faces several challenges specific to Vietnamese markets:
Tick size effects. Vietnamese exchanges use discrete tick sizes that vary with price level. At low prices, the minimum tick size represents a large fraction of the price, artificially widening the spread and distorting the structural parameters. The standard Glosten-Milgrom and PIN models assume continuous prices; adapting them to discrete price grids requires the modifications proposed by Bollen, Smith, and Whaley (2004).
Price limits. When a stock hits its price limit, the order book is effectively frozen at one side. Orders accumulate but cannot execute until the limit is lifted. This creates a censoring problem analogous to the price limit censoring in return distributions: the observed order flow is a truncated version of the latent flow.
Missing data. Full order book snapshots at high frequency are not universally available for Vietnamese stocks. PIN estimation requires only daily buy/sell counts, making it feasible with lower-frequency data. But richer models (Foucault (1999) dynamic limit order book, Roşu (2009) continuous-time model) require tick-by-tick order book data that may be unavailable for smaller stocks.
Institutional features. The coexistence of HOSE (continuous auction) and HNX (with periodic call auctions for less liquid stocks) creates heterogeneity in the appropriate structural model. A model estimated on HOSE continuous trading data does not directly apply to HNX call auction stocks.
43.6 When Structural Models Are Worth It
43.6.1 Decision Framework
Structural estimation is not always the right tool. The additional complexity, data requirements, and model risk must be justified by the research question. Table 43.6 provides a decision framework.
| Criterion | Structural Preferred | Reduced Form Preferred |
|---|---|---|
| Research question | Counterfactual or policy evaluation | Causal effect of observed variation |
| Data availability | Rich micro-data (transactions, holdings) | Standard panel (returns, fundamentals) |
| Institutional environment | Stable (model assumptions plausible) | Rapidly changing (model may be misspecified) |
| Number of parameters | Few primitives, many observables | Many parameters, few identifying assumptions |
| Publication venue | JF, RFS, Econometrica, JPE | JFE, RFS, MS, JFQA |
| Computational budget | Weeks to months of estimation | Hours to days |
43.6.2 Data Requirements
Structural models are data-hungry. Table 43.7 summarizes the minimum data needs for each model class covered in this chapter.
| Model | Minimum Data | Ideal Data | Vietnamese Availability |
|---|---|---|---|
| KY demand system | Quarterly institutional holdings + prices | 13F-equivalent filings + fund flows | Partial (semi-annual disclosure) |
| Almgren-Chriss | Daily volume, spread, volatility | Intraday order-by-order data | Good (HOSE provides) |
| Rock/IPO | Offer price, first-day close, allocation | Investor-level bids and allocations | Limited (auction data available) |
| PIN | Daily buy/sell counts (Lee-Ready) | Tick-by-tick trade and quote | Moderate (requires trade classification) |
| Glosten-Milgrom | Best bid/ask, trade direction | Full order book snapshots | Moderate (top-of-book available) |
43.6.3 Computational Cost
Structural estimation is orders of magnitude more expensive than reduced-form regression. The cost arises from three sources:
Likelihood evaluation. Each evaluation of the structural likelihood or moment function requires solving the model for given parameters. For dynamic models (e.g., inventory models, dynamic discrete choice), this involves solving a dynamic programming problem at each iteration.
Optimization. The GMM or MLE objective is typically non-convex, requiring multiple starting points and global optimization algorithms. The PIN model with 5 parameters requires \(\sim 10\) random starts; a richer model with \(\sim 20\) parameters might require hundreds.
Inference. Standard errors for structural parameters often require bootstrapping (because the asymptotic distribution is non-standard or the delta method is unreliable), adding a multiplicative factor of 200–1000 to the computational budget.
# Illustration: computational cost comparison
import time
# Reduced form: OLS regression
n_obs = 100000
X_sim = np.random.randn(n_obs, 5)
y_sim = X_sim @ np.random.randn(5) + np.random.randn(n_obs)
start = time.time()
for _ in range(100):
sm.OLS(y_sim, sm.add_constant(X_sim)).fit()
ols_time = (time.time() - start) / 100
# Structural: PIN estimation (single stock)
sim_buys = np.random.poisson(50, 250)
sim_sells = np.random.poisson(45, 250)
start = time.time()
pin_result = estimate_pin(sim_buys, sim_sells, n_starts=5)
pin_time = time.time() - start
print(f"Computational Cost Comparison:")
print(f" OLS (100k obs): {ols_time*1000:.1f} ms per estimation")
print(f" PIN (250 days, 5 starts): {pin_time:.1f} s per stock")
print(f" Ratio: {pin_time / ols_time:.0f}x")Computational Cost Comparison:
OLS (100k obs): 42.7 ms per estimation
PIN (250 days, 5 starts): 26.9 s per stock
Ratio: 632x
43.6.4 Interpretation Risks
The primary risk of structural estimation is model misspecification. If the model is wrong, the estimated parameters have no economic meaning and the counterfactual predictions are unreliable. Several strategies mitigate this risk:
Specification tests. Over-identifying restrictions (more moments than parameters) provide the Hansen \(J\)-test for model fit. A rejection suggests misspecification, though the test has low power in small samples.
Model comparison. Estimate multiple nested or non-nested models and compare fit. If a simpler model fits the data equally well, prefer it (Occam’s razor).
Sensitivity analysis. Report how structural parameters change under plausible alternative assumptions (e.g., different functional forms for the impact function, different distributional assumptions for valuations).
Out-of-sample validation. Estimate the model on one sample period and test its predictions on a holdout sample. Structural models that fit in-sample but fail out-of-sample are likely overfit.
43.6.5 Journal Expectations
Structural papers in top finance journals are held to specific standards:
Identification clarity. The paper must clearly state which parameters are identified, from which moments, and what variation in the data provides identification. The Rust (1987) critique that without clear identification, structural estimation is curve-fitting, must be addressed head-on.
Counterfactual credibility. Counterfactual exercises should be economically motivated and the range of counterfactual scenarios should not extrapolate far beyond the data.
Robustness to specification. Reviewers will ask what happens under alternative distributional assumptions, alternative moment conditions, and alternative model features.
Transparency. Code and data should be made available. Structural estimation is sufficiently complex that replicability is a first-order concern.
43.7 Summary
This chapter introduced structural estimation as a distinct methodology for financial economics, one that trades the simplicity and transparency of reduced-form analysis for the ability to estimate economic primitives and conduct counterfactual policy evaluation. We implemented four classes of structural models: demand systems for financial assets, optimal execution and transaction cost models, IPO auction models, and limit order book models of informed trading.
The demand system approach of Koijen and Yogo (2019) reveals that institutional investors in Vietnam exhibit heterogeneous demand elasticities across characteristics, with foreign investors showing different sensitivity patterns than domestic institutions. Market impact estimation confirms the universality of the square-root law, while the Almgren-Chriss framework provides the optimal execution benchmark for large institutional orders. The Rock IPO model quantifies the information asymmetry premium embedded in Vietnamese IPO underpricing, and the PIN model estimates the probability of informed trading across the cross-section.
Each model involves a tradeoff between the economic questions it can answer and the assumptions it requires. The decision to use structural estimation should be driven by the research question (specifically, whether counterfactual analysis is essential) and tempered by honest assessment of whether the model’s assumptions are sufficiently credible in the Vietnamese institutional environment. When they are, structural models provide insights that no amount of reduced-form regression can deliver.