| Overall Statistics |
|
Total Orders 102801 Average Win 0.01% Average Loss -0.01% Compounding Annual Return 0.868% Drawdown 6.100% Expectancy 0.011 Start Equity 50000000 End Equity 52210958.18 Net Profit 4.422% Sharpe Ratio -1.307 Sortino Ratio -1.548 Probabilistic Sharpe Ratio 0.000% Loss Rate 49% Win Rate 51% Profit-Loss Ratio 0.97 Alpha -0.038 Beta 0.01 Annual Standard Deviation 0.028 Annual Variance 0.001 Information Ratio -0.621 Tracking Error 0.144 Treynor Ratio -3.756 Total Fees $1403630.71 Estimated Strategy Capacity $1000000.00 Lowest Capacity Asset KEQU R735QTJ8XC9X Portfolio Turnover 6.34% Drawdown Recovery 348 |
BRIEF = """
IMPLEMENTATION BRIEF - "Enhancing the High-Volume Return Premium" (Mhin Kang, SSRN 4934371)
STRATEGY IN ONE LINE
Every trading day, among US common stocks with a SIGNIFICANTLY NEGATIVE contemporaneous correlation between
return and volume change (CCRV), go long (equal weight) the stocks that just had a HIGH-volume day and short
(equal weight) the stocks that just had a LOW-volume day; hold each daily portfolio for 20 trading days.
This is the paper's main "HN - LN" long-short portfolio (Section 3.1.3, Table 4).
1. UNIVERSE AND FILTERS (paper Section 3.2, screens follow Gervais, Kaniel, Mingelgrin 2001)
- Platform dataset: US Fundamental Data (Morningstar) via a daily add_universe(selector); every screen below uses
fields verified on a live Fundamental object (price, adjusted_price, volume, dollar_volume, market_cap,
security_reference.exchange_id / security_type / is_primary_share / ipo_date). Coverage begins 1998.
- Common stocks only: security_reference.security_type == "ST00000001" and is_primary_share (CRSP share codes
10/11 equivalent).
- Exchanges: NYSE, NYSE MKT (AMEX), NASDAQ -> security_reference.exchange_id in {"NYS", "ASE", "NAS"}.
- Listed at least one year: ipo_date <= classification day - 365 calendar days.
- Price screen: raw close (f.price) >= $5 on EVERY day of the 50-day reference window (classification day + 49
prior days); a stock that dips below $5 anywhere in the window is excluded that day.
- Volume screens: the stock must have a volume observation on all 50 days of the reference window (no
suspension / no missing volume) and at most 10 zero-volume days in the window.
- No size-decile exclusion (the decile-1 drop is used only for the size sub-samples in Table 2/5).
2. SIGNALS (daily data, per stock, recomputed every trading day theta from data through theta's close)
2a. Volume event (Section 3.1.2, Figure 1, Table 9 note): reference window = classification day theta plus the
previous 49 trading days (50 days). HIGH-volume if volume_theta is strictly greater than the 5th-highest volume
of the previous 49 days; LOW-volume if strictly lower than the 5th-lowest; otherwise mid-volume (ignored).
Volume = raw daily share volume (f.volume), the GKM 2001 convention.
2b. CCRV (Section 3.1.1, Table 2 / Figure 4 notes), 250-observation rolling window:
Stage 1: OLS of daily excess return R_it - R_ft on an intercept and the FF5 factors (Mkt-RF, SMB, HML, RMW,
CMA) at t-1, t, t+1 (Dimson 1979 lead/lag) -> 15 slopes + intercept; keep the residual eps_it.
Stage 2: OLS of eps_it on an intercept and dV_it = log(turnover_t / turnover_{t-1}),
turnover_t = dollar_volume_t / market_cap_t (f.dollar_volume / f.market_cap). gamma_i = CCRV.
Classification: t(gamma) < -1.645 -> negative CCRV (the only group traded); > 1.645 positive; else none.
Daily return R_it from f.adjusted_price (split/dividend adjusted close-to-close).
Computation is vectorised across the whole cross-section (one factor matrix, one projection).
3. PORTFOLIO CONSTRUCTION
- Each day theta: HN = negative-CCRV AND high-volume stocks; LN = negative-CCRV AND low-volume stocks.
- Daily portfolio theta: long HN equal-weighted, short LN equal-weighted, dollar neutral: +0.5 spread over HN,
-0.5 spread over LN (100 pct gross per tranche); if one leg is empty that day, that tranche trades only the other
leg at its 0.5 gross (the paper's daily EW portfolios are formed whenever members exist).
- Held 20 trading days => 20 overlapping daily tranches live at once, each with 1/20 of capital (this is exactly
the paper's own tradable realisation in Section 4.3.2 / Table 8 note: classify, hold 20 days, reclassify, with the
start postponed 1 day 19 times). Portfolio target weight of a stock = sum of its weights across the 20 live tranches.
- Starting cash 50,000,000 USD so the smallest tranche weight (0.5/20/~150 names) still buys > ~USD 8-10k.
4. REBALANCE TIMING AND HOLDING PERIOD
- Universe selection runs every trading day on the Fundamental snapshot that carries the previous close (theta).
- A scheduled event at 08:00 ET (daily resolution) on theta+1 orders the aggregate target dict with
set_holdings(targets, liquidate_existing_holdings=True); fills at the theta+1 open. The tranche formed on theta
is closed at the open of theta+21 (20 trading days held). Paper measures CAR over closes theta+1..theta+20; the
open-to-open realisation is the closest executable version on daily data (see DECISIONS).
5. ESTIMATION SCHEME
- No training/validation/test split: the method is fully rule-based with rolling windows (250 days CCRV, 50 days
volume). Windows roll forward one day at a time and are re-estimated every day.
- Warm-up: per-symbol daily state (returns, turnover, volume, price) accumulates in the selection function over
~260 trading days before the first classification.
6. SAMPLE / DATES
- Paper sample: July 1964 - March 2022 (CRSP). Platform: Fundamental universe from 1998. The final validation run
will cover 2023-01-01 to present (post-paper, genuinely out of sample), the default for this workflow; an
earlier start back to ~1999 is possible on request (compute heavy: daily 250-day regressions on ~4000 names).
7. DATASETS PER INPUT
- Prices, adjusted prices, share volume, dollar volume, market cap, exchange, share type, IPO date:
US Fundamental Data (Morningstar) universe snapshot - verified fields above.
- FF5 daily factors and RF: NOT AVAILABLE on QuantConnect Cloud (see REQUESTS).
DECISIONS
1. Main variant: HN-LN (significantly negative CCRV, t < -1.645), Table 4. Rejected: H-L unrestricted, HP-LP, the
sign-only CCRV<0 variant of Table 10 Panel B, and the practitioner fallback "H-L when HN/LN too small" (p20).
2. Volume measure for the high/low event: raw share volume (GKM 2001 convention, the paper lists "daily trading
volume" as a separate data item). Rejected: dollar volume or turnover for the event.
3. High/low threshold: strictly greater than the 5th-highest (strictly lower than the 5th-lowest) volume of the
previous 49 days; ties -> mid-volume. Rejected: >= / percentile interpolation.
4. Dimson lead term: the lead factor at theta+1 is unknown at theta, so the 250-observation window for both
regressions ends at theta-1 (observations theta-250 .. theta-1), keeping N = 250 with no look-ahead. Rejected:
window ending at theta (would need the next day's factor return).
5. Turnover log-change undefined when turnover is 0 on t or t-1: those observations are dropped from Stage 2 only
(pairwise deletion); Stage 1 uses all 250 return observations. Rejected: adding a small constant to volume.
6. A stock needs a full 250-day (plus lead/lag) history of returns and turnover to have a CCRV; without it it has no
CCRV and cannot be in HN/LN that day. Rejected: shorter windows for young names.
7. Daily return R_it = adjusted_price_t / adjusted_price_{t-1} - 1 (total return incl. dividends via adjusted data).
8. Price screen applied to the raw close on all 50 reference days (the paper: "price falls below $5 during the
reference period"). Rejected: only on the classification day.
9. Suspension screen: require a volume observation on all 50 reference days (the symbol must appear in the
universe snapshot every day of the window). Rejected: allow gaps.
10. Listing age: ipo_date at least 365 calendar days before the classification day.
11. Exchange filter: exchange_id in NYS/ASE/NAS (main-board listings). Rejected: BATS/ARCX/OTC names.
12. Portfolio realisation of "formed every day, held 20 days": 20 overlapping tranches, 1/20 of capital each,
each tranche dollar neutral (+0.5 / -0.5 gross), as in the paper's Section 4.3.2 construction. Rejected: a single
daily-rebalanced portfolio of currently-classified stocks (would not hold 20 days).
13. Leg gross when one leg is empty: the tranche keeps the non-empty leg at 0.5 (the paper reports single-leg
portfolios HN and LN separately). Rejected: skipping the tranche entirely.
14. Execution: orders at 08:00 ET on theta+1, filled at the theta+1 open; exit at the theta+21 open. Rejected: a
close fill on theta (not executable: classification needs theta's close volume).
15. Equal weights are set at formation and not re-scaled during the 20-day life (buy-and-hold within a tranche);
the aggregate dict is re-ordered daily with set_holdings so only the entering/exiting tranche generates trades
(drift trades on surviving tranches are small; recorded here because the paper is silent on intra-holding
rebalancing).
16. Market return for CAR (eq. 3) is a performance metric only, not part of the trading rule; not implemented as
a trading input.
17. Dates: backtest 2023-01-01 to present (platform default; paper sample ends March 2022). Adaptation forced by
data (Fundamental data from 1998) and compute cost.
REQUESTS
1. Daily Fama-French five-factor returns (Mkt-RF, SMB, HML, RMW, CMA) and RF (Ken French Data Library,
"Fama/French 5 Factors (2x3) [Daily]") are required by Stage 1 of the CCRV estimation and are NOT on
QuantConnect Cloud. Searched: the full dataset list (US Fundamental Data, FRED, Composite Factor Bundle, Cross
Asset Model, Data Link (needs external Nasdaq Data Link access), Cash Indices) - none carries FF factors.
Options for the client to choose:
(a) Build the FF5 factors inside the algorithm from the same Morningstar universe following Fama-French 2015
(June 2x3 sorts on size x B/M, size x operating profitability, size x investment; value-weighted daily
returns; RF from FRED 1-month T-bill). Faithful recipe, but a reconstruction, not Ken French's series,
and a large added component (its own DECISIONS list would follow).
(b) Client supplies the Ken French daily FF5 file as an Object Store key (the workflow's own rule forbids me
from uploading external data on my own; a client-provided file is the client's decision).
(c) ETF-based factor proxies (e.g. SPY-BIL, IWM-IWB, IWD-IWF, quality/low-investment ETFs) - approximations,
short histories for RMW/CMA proxies.
(d) Market factor only (Dimson CAPM residual) - a simplification of the paper's Stage 1.
I recommend (a) or (b). I will not proceed with any of them until the client picks one.
CLIENT DECISIONS (brief approved with these five answers; they override the text above)
C1. FF factors: use the platform FamaFrench custom dataset: add_data(FamaFrench, "FF", Resolution.DAILY).
Daily rows in decimals; columns marketexcessreturn, smb, hml, rmw, cma, momentum, riskfreerate; history from
Jan 1985; each row stamped end_time = factor date + 1 day -> factor date = end_time - 1 day. Verified in the
research notebook: row indexed 2024-06-01 = factors for 2024-05-31. Use the five factors in Stage 1 and
riskfreerate as RF. No proxies, no market-only fallback.
C2. Dates: start as early as data allows (equity + fundamental history from Jan 1998), no end date. Chosen:
start 1999-01-04 with a ~372-calendar-day warm-up (selection accumulates state from Jan 1998, first CCRV
available at ~252 observations). Short windows only for verification runs.
C3. Tranche mechanics: buy-and-hold within a tranche. Each day trade ONLY the entering tranche and the exiting
tranche (net share delta per symbol); leave the share counts of the other 18 tranches alone. Tranche notional
= total portfolio value / 20 at formation, +0.5 long / -0.5 short of it.
C4. Empty class: skip the tranche when HN or LN is empty (every tranche dollar neutral).
C5. Volume events: share-volume comparison must be split-consistent across the 50-day window. Implementation:
split-consistent volume = volume * price / adjusted_price (the adjustment factor also carries dividend
adjustments, a sub-1 pct effect that cannot create a top-5-of-49 event). Turnover unchanged.
Verified in notebook: the Fundamental snapshot dated D carries the close of the previous trading day
(snapshot 2024-05-31 adjusted_price 129.1389 = A close 2024-05-30), so theta = the trading day before the
selection date and orders at 08:00 on D fill at D's open = theta+1 open.
Additional conventions fixed at build time (not in the paper):
- Stage 2 requires at least 125 valid (finite) dV observations of the 250 after pairwise deletion.
- Missing ipo_date: the 252-consecutive-observation requirement for CCRV already enforces ~1 year of listing;
the ipo_date screen is applied whenever ipo_date is populated.
C6 (client, after first full runs): backtest window set_start_date(2021, 9, 1), set_end_date(2026, 9, 1);
the 252-fire warm-up (372 calendar days) supplies everything the method needs from before the start.
C7 (client): exclude closed-end funds, depositary receipts and REITs (CRSP share-code 10/11 analogue).
Flags verified on live Fundamental objects (research notebook cell 2, snapshot 2024-05-31):
security_reference.common_share_sub_type == 'Closed End Fund' (79 names, incl. NZF/GLU/RSF/EVV/ERC/NVG/FCO/BIT),
company_reference.is_reit (190 names, e.g. O, PLD), security_reference.is_depositary_receipt (never True in this
data: ADRs such as BABA/TSM/NVO carry no Morningstar security reference and were already dropped by the
security_type/is_primary_share filter; AZN shows ST00000001 but is_primary_share False). Names with no
company_reference are dropped as unverifiable. First-fire check 2020-08-25: 91 CEF/DR and 175 REIT exclusions.
- Sizing price for a new tranche = the classification-day ADJUSTED close from the snapshot (f.adjusted_price);
shares floored. (Corrected after the first full run: LEAN trades the split/dividend-adjusted series, so
sizing on the raw close mis-scaled positions by each name's future cumulative split factor - reverse-split
small caps in the short leg were shorted many times oversize and drove a -61% full-period result.)
- Platform realization (after order checks of the C6/C7 runs): every 08:00 rebalance first cancels any open
order left from the previous day (a halted stock's market-on-open order otherwise stays live and can fill
months later at an unrelated price - YNDX/NBIS filled 13 months after its 2022-02-28 tranche entry), then
sweeps stale holdings (any invested name that no live tranche wants is ordered back to its target).
- Blocked by Security ID as confirmed data glitches (flat-lined zero-volume series, research notebook cells 3-5):
NEP VRPPHKA7GFAD (NextEra Energy Partners -> XPLR rename not mapped; NEP flat at 10.54 from 2025-02-03 while
XIFR 2T is a separate security), DV R735QTJ8XC9X (ATGE flat at 98.54 from 2026-02-24),
IAC XFSYMYY1JYW5 (flat at 42.24 from 2026-06-04). Without the blocks their exit orders were canceled daily
to the end of the sample and the stale marks stayed in the equity curve.
"""
# region imports
from AlgorithmImports import *
import numpy as np
# endregion
EXCHANGES = {"NYS", "ASE", "NAS"}
COMMON_STOCK = "ST00000001"
CLOSED_END_FUND = "Closed End Fund" # Morningstar security_reference.common_share_sub_type
WINDOW = 252 # ring length: 250 regression observations + 1 lag price/turnover + lead factor day
REF_DAYS = 50 # volume reference window (classification day + 49 prior days)
HOLD_DAYS = 20 # holding period in trading days = number of live tranches
MIN_PRICE = 5.0
MAX_ZERO_VOLUME_DAYS = 10
T_CRIT = 1.645
MIN_STAGE2_OBS = 125
class Tranche:
"""One daily HN-LN portfolio: fixed share counts held for HOLD_DAYS trading days."""
def __init__(self, day_index: int, shares: dict) -> None:
self.day_index = day_index
self.shares = shares # Symbol -> signed share count
class VolumePremium(QCAlgorithm):
def initialize(self) -> None:
self.set_start_date(2021, 9, 1)
self.set_end_date(2026, 9, 1)
self.set_cash(50_000_000)
self.settings.seed_initial_prices = True
self.universe_settings.resolution = Resolution.DAILY
self.set_warm_up(timedelta(days=372))
self._ff = self.add_data(FamaFrench, "FF", Resolution.DAILY).symbol
# cross-sectional ring buffers (rows = stocks, columns = trading days)
self._cap = 12000
self._ptr = -1
self._day = 0
self._rows = {} # Symbol -> row index
self._row_symbols = [] # row index -> Symbol
self._adj = np.full((self._cap, WINDOW), np.nan)
self._prc = np.full((self._cap, REF_DAYS), np.nan)
self._vol = np.full((self._cap, REF_DAYS), np.nan)
self._tov = np.full((self._cap, WINDOW), np.nan)
self._ipo = np.full(self._cap, np.nan)
self._last_seen = np.full(self._cap, -1)
self._factors = np.full((WINDOW, 5), np.nan)
self._rf = np.full(WINDOW, np.nan)
self._factor_end_time = None
self._tranches = []
self._pending = None
self._universe = self.add_universe(self._select_assets)
self.schedule.on(self.date_rules.every_day("SPY"), self.time_rules.at(8, 0), self._trade)
# ------------------------------------------------------------------ universe
def _row_for(self, symbol: Symbol) -> int:
row = self._rows.get(symbol)
if row is None:
if len(self._row_symbols) >= self._cap:
self._grow()
row = len(self._row_symbols)
self._row_symbols.append(symbol)
self._rows[symbol] = row
return row
def _grow(self) -> None:
extra = self._cap // 2
self._adj = np.vstack([self._adj, np.full((extra, WINDOW), np.nan)])
self._prc = np.vstack([self._prc, np.full((extra, REF_DAYS), np.nan)])
self._vol = np.vstack([self._vol, np.full((extra, REF_DAYS), np.nan)])
self._tov = np.vstack([self._tov, np.full((extra, WINDOW), np.nan)])
self._ipo = np.concatenate([self._ipo, np.full(extra, np.nan)])
self._last_seen = np.concatenate([self._last_seen, np.full(extra, -1)])
self._cap += extra
def _select_assets(self, fundamentals: List[Fundamental]) -> List[Symbol]:
self._day += 1
self._ptr = self._day % WINDOW
p = self._ptr
pr = self._day % REF_DAYS
self._adj[:, p] = np.nan
self._tov[:, p] = np.nan
self._prc[:, pr] = np.nan
self._vol[:, pr] = np.nan
for f in fundamentals:
ref = f.security_reference
if ref is None or ref.security_type != COMMON_STOCK or not ref.is_primary_share:
continue
if ref.exchange_id not in EXCHANGES:
continue
# CRSP share-code 10/11 analogue: no closed-end funds, depositary receipts or REITs
if ref.common_share_sub_type == CLOSED_END_FUND or ref.is_depositary_receipt:
continue
cref = f.company_reference
if cref is None or cref.is_reit:
continue
adj = f.adjusted_price
price = f.price
if adj <= 0 or price <= 0:
continue
row = self._row_for(f.symbol)
self._adj[row, p] = adj
self._prc[row, pr] = price
self._vol[row, pr] = f.volume * price / adj # split-consistent share volume
mcap = f.market_cap
self._tov[row, p] = f.dollar_volume / mcap if mcap > 0 else np.nan
ipo = ref.ipo_date
if ipo is not None and ipo.year > 1900:
self._ipo[row] = ipo.toordinal()
self._last_seen[row] = self._day
self._update_factors()
self._pending = None
if self._day < WINDOW or self.is_warming_up:
return []
t_stat, nobs = self._ccrv()
neg = np.isfinite(t_stat) & (nobs >= MIN_STAGE2_OBS) & (t_stat < -T_CRIT)
screen, high, low = self._volume_events()
hn = np.flatnonzero(screen & neg & high)
ln = np.flatnonzero(screen & neg & low)
if len(hn) and len(ln):
# a new daily tranche: symbols and their classification-day ADJUSTED closes for sizing.
# The traded price series is split/dividend adjusted, so shares must be sized on the same
# basis; sizing on the raw close would mis-scale every name by its future split factor.
self._pending = (self._day,
{self._row_symbols[i]: self._adj[i, p] for i in hn},
{self._row_symbols[i]: self._adj[i, p] for i in ln})
selected = set()
for tranche in self._tranches:
selected.update(tranche.shares.keys())
if self._pending is not None:
selected.update(self._pending[1].keys())
selected.update(self._pending[2].keys())
return list(selected)
# ------------------------------------------------------------------ signals
def _ccrv(self) -> tuple:
"""Two-stage CCRV (paper eqs 1-2) over 250 observations ending at theta-1, vectorised across stocks.
Returns (t-statistic of gamma, number of valid stage-2 observations) per row."""
n = len(self._row_symbols)
idx = [(self._ptr + 1 + i) % WINDOW for i in range(WINDOW)] # chronological; last = theta
nobs_reg = WINDOW - 2 # 250
A = self._adj[:n][:, idx]
T = self._tov[:n][:, idx]
F = self._factors[idx]
rf = self._rf[idx]
with np.errstate(divide="ignore", invalid="ignore"):
ret = A[:, 1:] / A[:, :-1] - 1.0 # dates theta-250..theta
y = ret[:, :nobs_reg] - rf[1:nobs_reg + 1] # excess returns theta-250..theta-1
# stage 1: Dimson lead/lag FF5 regression -> residuals via the annihilator matrix
x = np.column_stack([np.ones(nobs_reg), F[0:nobs_reg], F[1:nobs_reg + 1], F[2:nobs_reg + 2]])
m = np.eye(nobs_reg) - x @ np.linalg.pinv(x)
e = y @ m
# stage 2: residual on log turnover change, pairwise deletion of undefined dV
dv = np.log(T[:, 1:nobs_reg + 1] / T[:, 0:nobs_reg])
mask = np.isfinite(dv) & np.isfinite(e)
w = mask.astype(float)
nobs = w.sum(1)
dv0 = np.where(mask, dv, 0.0)
e0 = np.where(mask, e, 0.0)
mx = dv0.sum(1) / nobs
me = e0.sum(1) / nobs
dxc = (dv0 - mx[:, None]) * w
exc = (e0 - me[:, None]) * w
sxx = (dxc ** 2).sum(1)
sxy = (dxc * exc).sum(1)
gamma = sxy / sxx
res = exc - gamma[:, None] * dxc
s2 = (res ** 2).sum(1) / (nobs - 2)
t_stat = gamma / np.sqrt(s2 / sxx)
return t_stat, nobs
def _volume_events(self) -> tuple:
"""GKM screens and high/low volume classification on the 50-day reference window."""
n = len(self._row_symbols)
idr = [(self._day + 1 + i) % REF_DAYS for i in range(REF_DAYS)] # chronological; last = theta
v = self._vol[:n][:, idr]
pr = self._prc[:n][:, idr]
complete = np.isfinite(v).all(1) & np.isfinite(pr).all(1)
v0 = np.where(np.isfinite(v), v, 0.0)
prev = np.sort(v0[:, :-1], axis=1)
today = v0[:, -1]
high = today > prev[:, -5]
low = today < prev[:, 4]
zeros_ok = (v0 == 0).sum(1) <= MAX_ZERO_VOLUME_DAYS
price_ok = np.where(np.isfinite(pr), pr, 0.0).min(1) >= MIN_PRICE
theta_ord = self.time.toordinal() - 1
ipo = self._ipo[:n]
ipo_ok = np.isnan(ipo) | (ipo <= theta_ord - 365)
seen = self._last_seen[:n] == self._day
screen = complete & zeros_ok & price_ok & ipo_ok & seen
return screen, high, low
def _update_factors(self) -> None:
"""Store the FF5 row for the classification day theta (row end_time = theta + 1 <= selection date)."""
rows = list(self.history[FamaFrench](self._ff, 10, Resolution.DAILY))
p = self._ptr
if not rows:
self._factors[p] = np.nan
self._rf[p] = np.nan
return
last = rows[-1]
end_time = last.end_time
if self._factor_end_time is not None and end_time <= self._factor_end_time:
self._factors[p] = np.nan # stale row: no new factor for this day
self._rf[p] = np.nan
return
self._factor_end_time = end_time
self._factors[p] = [last.market_excess_return, last.smb, last.hml, last.rmw, last.cma]
self._rf[p] = last.risk_free_rate
# ------------------------------------------------------------------ trading
def _trade(self) -> None:
"""08:00 ET on theta+1: close the tranche formed HOLD_DAYS fires ago, open the tranche formed on theta.
Only these two tranches trade; the others keep their share counts (buy-and-hold within a tranche)."""
if self.is_warming_up:
return
# an order that did not fill on its intended day (trading halt) must not fill months later
self.transactions.cancel_open_orders()
touched = set()
exiting = [t for t in self._tranches if t.day_index <= self._day - HOLD_DAYS]
for t in exiting:
touched.update(t.shares.keys())
self._tranches.remove(t)
if self._pending is not None:
day_index, longs, shorts = self._pending
self._pending = None
notional = self.portfolio.total_portfolio_value / HOLD_DAYS
shares = {}
for leg, sign in ((longs, 1), (shorts, -1)):
per_name = 0.5 * notional / len(leg)
for symbol, price in leg.items():
if not self._tradable(symbol):
continue
qty = sign * int(per_name / price)
if qty != 0:
shares[symbol] = qty
if shares:
self._tranches.append(Tranche(day_index, shares))
touched.update(shares.keys())
# holdings no live tranche accounts for (e.g. an exit skipped while the stock was halted) are flattened
live = set()
for t in self._tranches:
live.update(t.shares.keys())
for holding in self.portfolio.values():
if holding.invested and holding.symbol not in live:
touched.add(holding.symbol)
for symbol in touched:
desired = sum(t.shares.get(symbol, 0) for t in self._tranches)
delta = desired - self.portfolio[symbol].quantity
if delta != 0 and self._tradable(symbol):
self.market_order(symbol, delta)
def _tradable(self, symbol: Symbol) -> bool:
if symbol not in self.securities:
return False
security = self.securities[symbol]
return security.is_tradable and not security.is_delisted and security.price > 0
NOTES = """
PAPER: "Enhancing the High-Volume return premium" - Mhin Kang (Kangnam Univ.), SSRN 4934371. 43 pages.
p1 ABSTRACT: Enhance high-volume return premium (HVRP, Gervais Kaniel Mingelgrin 2001) by applying it only
to stocks with NEGATIVE contemporaneous correlation between return and volume changes (CCRV).
Negative-CCRV HVRP exceeds unrestricted HVRP by >30%. US stock market.
p2 INTRO: HVRP = future return of abnormally-high-volume stock > abnormally-low-volume stock.
Chae and Kang (2019) decompose HVRP; "return by CCRV" = mean reversion of volume x CCRV sign.
Volume mean-reverts after shock; return moves same (opposite) direction as volume if CCRV positive (negative).
p3 PROCEDURE (intro summary):
1. Identify CCRV via rolling windows: regress the return residual of daily factor models on daily change in
trading volume (text says "regressing the change in trading volume on the return residual" - check body).
If t-value of coefficient on change in volume > 1.645 -> positive CCRV stock; < -1.645 -> negative CCRV.
2. Volume classification per Gervais et al (2001): if a stock's trading volume is larger (smaller) than the
top (bottom) 10% of the previous 50 days' trading volume -> high-volume (low-volume) stock.
3. Construct negative-CCRV high-volume (low-volume) portfolio = high (low) volume stocks within negative CCRV
group, FOR EACH TRADING DAY of the sample (footnote 4: portfolios formed every day, unlike GKM 2001 who
classify every 70 days). Also positive-CCRV HV/LV portfolios and unrestricted HV/LV portfolios for comparison.
4. Measure 20-day performance after formation (CAR, cumulative abnormal return).
Main result: long negative-CCRV HV / short negative-CCRV LV: CAR 1.36% avg (20 days). Positive-CCRV L/S: 0.71%.
p4: Unrestricted HV-LV L/S CAR 1.01%; enhancement 34.33%. Consistent across exchanges (NYSE, NASDAQ, NYSE MKT
= former AMEX) and size (large, medium, small) subsamples. Paper organization: Ch3 methodology & data, Ch4
results (mean-reversion of volume, CCRV distribution, portfolio performance comparison).
p5-6: Literature review only (GKM 2001 visibility hypothesis; Kaniel et al 2012; Huang et al 2011; Chae & Kang 2019
decompose HVRP into return by systematic factors, return by CCRV, return by other sources).
p6 Hypotheses: stocks classified into positive CCRV, negative CCRV, and insignificant CCRV. Return by CCRV of
negative-CCRV stocks becomes positive under HVRP (via volume mean reversion).
p7-8 Hypotheses H1 (volume mean reverts), H2 (negative CCRV stocks exist), H3a (neg-CCRV HVRP > unrestricted HVRP),
H3b (pos-CCRV HVRP < unrestricted). Tested on entire market, sub-group, sub-period.
p8 SECTION 3.1.1 ESTIMATION OF CCRV (KEY):
Step 1: Regress daily excess return on Fama-French FIVE factor model (FF 2015) with Dimson (1979) lead/lag:
R_it - R_ft = alpha_i + sum_k lambda_kt * beta_ik + eps_it (1)
lambda_kt = factor-mimicking portfolio returns on day t, t-1 AND t+1 (Dimson one-day lead and lag).
=> regressors: 5 FF factors x 3 (t-1, t, t+1) = 15 regressors + intercept.
Residual eps_it = "other returns" containing return by CCRV.
Step 2: Regress residual on change in log turnover:
eps_it = delta_i + gamma_i * dV_it + xi_it (2)
V_it = log of daily trading volume TURNOVER = dollar volume of stock i / market cap on day t.
dV_it = change in V (V_t - V_{t-1}).
gamma_i = CCRV; its t-value = significance.
Window: 250-day moving window. CCRV of stock i on day theta uses window [theta-249, theta] for eqs (1),(2).
Classification each day theta: t(gamma) > 1.645 -> positive CCRV; t(gamma) < -1.645 -> negative CCRV;
otherwise insignificant.
NOTE: Dimson lead term at t+1 means the window's last day regression needs factor return on theta+1; with
window ending at theta, the last usable observation for eq (1) is theta-1 if strictly no look-ahead (paper
silent on this - DECISION).
p9 SECTION 3.1.2 HIGH/LOW VOLUME CLASSIFICATION (Chae & Kang 2019 modification of Kaniel et al 2012 / GKM 2001):
Stock is HIGH-volume (LOW-volume) on classification day when its trading volume that day is higher (lower) than
its FIFTH TOP (FIFTH BOTTOM) trading volume of the previous 49 days; all others mid-volume. (i.e. reference period
= classification day + previous 49 days = 50 days; today's volume in top 10% / bottom 10% of the 50-day window.)
Classification done EVERY trading day (moving windows). Test period = following 20 trading days.
Moving window total = 70 trading days (50 reference + 20 test). Figure 1 illustrates.
"Trading volume" for this classification: not yet specified whether raw share volume, dollar volume or turnover
(GKM 2001 use raw share volume; check later pages).
p10 SECTION 3.1.3 PORTFOLIO CONSTRUCTION: each day, stock in one of 3x3 subgroups (CCRV sign x volume event).
SIX EQUALLY WEIGHTED portfolios formed every trading day: HP (pos-CCRV high-vol), HN (neg-CCRV high-vol),
H (all high-vol), LP, LN, L. Long-short: HP-LP, HN-LN, H-L. Monitored 20 days after formation.
Footnote 9: mid-volume stocks ignored.
Performance: CAR_i = sum_{t=theta+1}^{theta+20} (R_it - R_mt) (3); R_mt market return, R_it raw return.
(Sum of daily market-adjusted returns, not compounded.) H3a: compare CAR(HN)-CAR(LN) vs CAR(H)-CAR(L).
MAIN STRATEGY = HN - LN long-short (negative-CCRV high-volume long, negative-CCRV low-volume short),
equal-weighted, formed daily, held 20 trading days -> overlapping portfolios in a live implementation.
p11 SECTION 3.2 DATA: all common stocks on NYSE, NYSE MKT (AMEX), NASDAQ, July 1964 - March 2022, CRSP.
Data: daily price, daily adjusted price, daily trading volume, daily shares outstanding, daily dollar volume.
Factor data from Ken French website (FF5 daily).
SCREENS (following GKM 2001):
- exclude stocks whose price falls below $5 during the reference period (the 50-day volume window);
- exclude stocks whose trading was suspended during the volume observation period and stocks with no volume info;
- exclude stocks listed publicly for less than one year;
- exclude stocks whose trading volume records zero more than TEN times during the reference period.
p11-12 4.1 Volume mean reversion (Figure 3, Table 1): volume ratio = log(daily vol / vol on classification day);
HV stocks' volume drops back within 1-2 days after, LV stocks' rises back. H1 verified.
p12 4.2 CCRV distribution (Figure 4): t-values of gamma centered ~0, platykurtic. Significantly negative ~13.52%,
significantly positive ~21.30% of stock-days. Sample 1965-2022.
p13 Table 2: size groups per GKM 2001: market-cap deciles 9-10 large, 6-8 middle, 2-5 small, decile 1 IGNORED.
(Deciles are of the whole sample presumably; whether decile-1 is dropped from the main analysis is not stated
explicitly here - the main whole-sample analysis appears to use all screened stocks.)
Neg-CCRV share: NYSE 13.41%, NYSE MKT 12.26%, NASDAQ 14.41%; small 16.79%, mid 12.18%, large 10.96%.
p13-14 Table 3: successive probability (same class next day) > 20% (volume autocorrelation) -> overlapping.
Class proportions whole sample: HN 1.5%, LN 1.1%, HP/LP > 2%; after 2001: H and L ~10% each, HP/LP/HN/LN ~2%.
Some days have no stock in a class (LN p10 = 4 stocks whole sample). Insufficient-member issue addressed later.
p14 4.3 tests: whole sample, subgroups, subperiods, risk-adjusted HVRP, robustness (Chae&Kang modified GKM method,
insufficient members).
p14-15 4.3.1 Figure 5, Table 4: 20-day L/S CAR: H-L 1.01%, HN-LN 1.36%, HP-LP 0.71%. Newey-West t-values.
Enhancement +34.33%, reduction -29.49%. HN line above H before day 13 then below (due to systematic-factor
returns differing by size). Bootstrapping CI used because of overlapping test periods (19-day overlap).
p16 Bootstrap 1000x. Table 5 subsamples: HN-LN NYSE 1.12%, NYSE MKT 1.32%, NASDAQ 1.48%; small 1.73%, mid 1.37%,
large 0.69%. Higher L/S profit comes from the short side (LN). Table 6: six decades, HN-LN > H-L in all.
Table 7: correlations of yearly portfolio perf with factor averages (negative with market).
p17 4.3.2 RISK-ADJUSTED HVRP - alternative portfolio construction (Gordon & Wu 2018 / Chae & Kang 2019):
1) classify on first day of sample; 2) classification valid for following 20 days; 3) after 20 days reclassify;
4) repeat to end. Yields continuous daily return time series per class. Then repeat postponing start by 1 day,
19 times -> 20 series per class (i.e. 20 overlapping tranches each rebalanced every 20 days). Factor regressions
on those. Table 8: daily alpha FF5: H-L 0.0575%/day, HN-LN 0.0822%/day, HP-LP 0.0449%/day.
Footnote 12: no Dimson regression for portfolio-level factor analysis (Dimson only used in CCRV estimation).
NOTE: the Gordon&Wu construction (20 staggered tranches each held 20 days) is the natural tradable realization
of "form portfolios every day and hold 20 days": at any time 20 overlapping cohorts, each 1/20 of capital.
p19: Profit source roughly equal from long and short sides in risk-adjusted analysis.
p19 4.3.3 Original GKM method (classify every 70 days, hold 20; postponed 69 times). Table 9 averages = Table 4.
p20 4.3.4 Insufficient members: criteria min class members >5, >10, >15, >20 (Table 10 Panel A) - results
consistent. Footnote 14: since 2004 only 151 of 4565 days had class members < 10. Suggestion for practitioners:
use HN-LN when enough members, H-L otherwise (this is a suggestion, not the main method).
Panel B alternative: loosen "significantly negative CCRV (t<-1.645)" to "CCRV<0": L/S 1.21% vs CCRV>0 0.92%
(improvement 19.43%). Alternative only, main method = t<-1.645.
p21 Conclusion: main strategy = HN-LN L/S; ~30% enhancement over H-L. Robust across exchanges and size.
p22-24 Conclusion continued + References (Chae & Kang 2019 PBFJ 58 101204; Dimson 1979 JFE; FF 2015; GKM 2001 JF;
Gordon & Wu 2018 PBFJ 51; Kaniel Ozoguz Starks 2012 JFE; Newey West 1987; Podobnik et al 2009 PNAS).
p25 Table 1 note: volume ratio = log(vol_t / vol_classification_day).
p25-26 Table 2 NOTE (KEY WORDING): "we regress the daily return of each stock by factors of the FF5 model first.
Then we regress the residual of the first regression by the LOGARITHM OF CLASSIFICATION DAY'S TURNOVER OVER THE
PREVIOUS DAY'S TURNOVER" => dV_t = log(turnover_t / turnover_{t-1}) = log turnover change. t-value on this
coefficient: >1.645 positive CCRV, <-1.645 negative CCRV. Size deciles: 9-10 large, 6-8 mid, 2-5 small.
p26-27 Table 3: 2001-2022: H avg 443 stocks (10.9% of market), HN avg 74.3 (1.8%; p10=34, p50=65), L avg 430,
LN avg 69.8 (1.7%; p10=22, p50=60). Whole market ~4000 stocks per day post-2001. Successive prob ~30%.
p28-30 Tables 4,5: results tables only (CARs by class/subgroup). Table 5 note: growth/value = Be/Me top/bottom
30%, negative Be/Me excluded (subsample only, not part of the main method).
p31 Table 6 subperiods: HN-LN 2005-2015 0.81%, 2015-2022 0.50% (H-L 0.22%, HP-LP -0.30%). Decaying over time.
p32 Table 7 correlations (no method info). p33 Table 8 note: repeats Gordon&Wu construction (classify day 1,
valid 20 days, reclassify, repeat; postpone start 1 day x19 -> 20 series). Factor models FF3, Carhart4, FF5, PS.
p34-36 Table 9 note: "Each subperiod is composed of 49 days' reference period, classification day, and 20 days'
test period" -> confirms 50-day volume window INCLUDES the classification day. Table 10 Panel A/B (robustness
tables, values only). Panel B alternative: sign-only CCRV condition.
p37-43 Figure notes: Fig 1 re-confirms high (low) volume = today's volume higher (lower) than fifth top (bottom)
of previous 49 days. Fig 4 repeats CCRV recipe (FF5 residual regressed on log(turnover_t/turnover_{t-1})).
Fig 5: portfolios equal-weighted within sub-class. Fig 6 annual HVRPs. No appendix / no source code link.
END OF PAPER.
OPEN POINTS FOR BRIEF/DECISIONS:
- "trading volume" in the HIGH/LOW classification: raw daily share volume (GKM 2001, Chae&Kang 2019) vs turnover.
Paper lists "daily trading volume" and dollar volume among data; turnover only defined in CCRV step. Use raw
share volume for the volume-event classification (GKM convention), turnover for CCRV.
- Dimson lead term: with the window ending at theta, the eq (1) observation for day theta needs factor at
theta+1 (unknown at theta). Decision: run eq (1),(2) over days theta-249..theta-1 where lead is available,
OR treat lead as t+1 available only for t<theta. Need to choose.
- FF5 daily factors: not on QC. Need substitute or client-provided data. Search datasets.
- Market return R_m for CAR: only used for performance evaluation, not trading.
- Ties at "fifth top": strictly higher than 5th-highest of the previous 49 days.
- Overlapping daily formation & 20-day hold -> 20 tranches each with 1/20 of capital (Gordon-Wu construction
described in section 4.3.2 as the risk-adjusted realization). Each tranche: long EW HN, short EW LN, dollar-neutral.
- Price screen: $5 minimum during 50-day reference period; zero-volume >10 days in reference period excluded;
listed >= 1 year; common stocks only (CRSP share codes 10/11 -> ST00000001 + primary share).
- Exclude size decile 1? Only for size subsamples per notes; main whole-market analysis uses all screened stocks.
"""