| name | minervini-swing-trading |
| description | Trade swing setups in the style of Mark Minervini, 3x US Investing Champion with 220%+ annual returns. Emphasizes SEPA methodology, trend templates, volatility contraction patterns (VCP), and strict risk management. Use when swing trading momentum stocks, identifying breakout setups, or building systematic trend-following strategies. |
| tags | swing-trading, momentum, technical-analysis, stocks, trading, finance, trend-following, risk-management, screening |
Mark Minervini Swing Trading Style Guide
Overview
Mark Minervini is a 3-time US Investing Champion who turned $100,000 into over $30 million. His SEPA (Specific Entry Point Analysis) methodology combines trend analysis, volatility contraction patterns, and strict risk management into a repeatable system. He emphasizes buying leading stocks at specific low-risk entry points within confirmed uptrends.
Core Philosophy
"The goal is not to buy low and sell high. It's to buy high and sell higher."
"Risk management is not about avoiding losses—it's about keeping losses small so you can stay in the game."
"I don't buy stocks that are going up. I buy stocks that are going up the right way."
Minervini believes that most of the money in the stock market is made in the middle of a move, not at the bottom. By waiting for stocks to prove themselves in a Stage 2 uptrend, you trade with the trend while managing risk through precise entries.
Design Principles
-
Trend First: Only buy stocks in a confirmed Stage 2 uptrend.
-
Specific Entry Points: Enter at low-risk pivot points, not randomly.
-
Volatility Contraction: Tightening price action precedes explosive moves.
-
Cut Losses Quickly: 7-8% maximum loss, often tighter.
-
Let Winners Run: Sell strength, not weakness.
The Trend Template (8 Criteria)
A stock MUST pass ALL 8 criteria before consideration:
1. Current price > 150-day MA
2. Current price > 200-day MA
3. 150-day MA > 200-day MA
4. 200-day MA trending up for at least 1 month (ideally 4-5 months)
5. 50-day MA > 150-day MA AND 50-day MA > 200-day MA
6. Current price > 50-day MA
7. Current price at least 25% above 52-week low
8. Current price within 25% of 52-week high (ideally within 15%)
Stage Analysis:
- Stage 1: Basing/accumulation (avoid)
- Stage 2: Advancing/uptrend (BUY ZONE)
- Stage 3: Topping/distribution (avoid)
- Stage 4: Declining/downtrend (avoid)
Volatility Contraction Pattern (VCP)
The VCP is Minervini's signature setup:
VCP Structure:
Price T1
| /\
| / \ T2
| / \ /\ T3
| / X \ /\ Pivot
| / | X \ /----→ BREAKOUT
| / | | \/
|/ | |
Base C1 C2 C3 (contractions tighten)
T = Thrust (price expansion)
C = Contraction (price tightening)
VCP Characteristics:
- Minimum 2 contractions, ideally 3-4
- Each contraction is SHALLOWER than the previous
- Contractions: 1st: 20-35%, 2nd: 10-20%, 3rd: 5-15%, 4th: 3-8%
- Volume DECREASES during contractions (supply drying up)
- Volume INCREASES on breakout (demand returning)
When Swing Trading
Always
- Confirm stock passes ALL 8 trend template criteria
- Wait for a proper VCP or constructive base
- Enter on breakout above pivot with volume surge (50%+ above average)
- Set stop at 7-8% maximum (tighter if possible based on structure)
- Have a sell plan BEFORE you enter
- Trade liquid stocks (avg volume > 400K)
Never
- Buy a stock in Stage 1, 3, or 4
- Chase extended stocks (>10% above pivot)
- Average down on a losing position
- Hold through an 8%+ loss
- Buy on light volume breakouts
- Ignore relative strength vs market
Prefer
- Stocks with RS Rating > 85 (top 15% performers)
- EPS growth > 25% recent quarters
- Tight consolidations (VCP) over wide-and-loose bases
- Breakouts from IPO bases or first Stage 2 breakouts
- Industry group strength (top 20% of groups)
- Institutional accumulation (up weeks on volume)
Code Patterns
Trend Template Scanner
class TrendTemplateScanner:
"""
Minervini Trend Template: all 8 criteria must pass.
This is the first filter—non-negotiable.
"""
def check_trend_template(self,
df: pd.DataFrame,
min_200ma_uptrend_days: int = 22) -> TrendTemplateResult:
"""
Check if stock passes all 8 trend template criteria.
"""
close = df['close']
ma_50 = close.rolling(50).mean()
ma_150 = close.rolling(150).mean()
ma_200 = close.rolling(200).mean()
current_price = close.iloc[-1]
current_50ma = ma_50.iloc[-1]
current_150ma = ma_150.iloc[-1]
current_200ma = ma_200.iloc[-1]
high_52w = close.rolling(252).max().iloc[-1]
low_52w = close.rolling(252).min().iloc[-1]
ma_200_month_ago = ma_200.iloc[-min_200ma_uptrend_days]
ma_200_trending_up = current_200ma > ma_200_month_ago
criteria = {
'1_price_above_150ma': current_price > current_150ma,
'2_price_above_200ma': current_price > current_200ma,
'3_150ma_above_200ma': current_150ma > current_200ma,
'4_200ma_trending_up': ma_200_trending_up,
'5_50ma_above_150_and_200': (current_50ma > current_150ma) and (current_50ma > current_200ma),
'6_price_above_50ma': current_price > current_50ma,
'7_price_25pct_above_52w_low': current_price >= low_52w * ,
: current_price >= high_52w * ,
}
all_pass = (criteria.values())
TrendTemplateResult(
passes=all_pass,
criteria=criteria,
stage=.determine_stage(df, criteria),
price=current_price,
ma_50=current_50ma,
ma_150=current_150ma,
ma_200=current_200ma,
pct_from_52w_high=(current_price - high_52w) / high_52w * ,
pct_from_52w_low=(current_price - low_52w) / low_52w *
)
() -> :
(criteria.values()):
close = df[]
ma_200 = close.rolling().mean()
close.iloc[-] < ma_200.iloc[-] ma_200.iloc[-] < ma_200.iloc[-]:
close.iloc[-] < ma_200.iloc[-] * :
() -> [TrendTemplateResult]:
results = []
symbol symbols:
df = data[symbol]
(df) < :
result = .check_trend_template(df)
result.symbol = symbol
result.passes:
results.append(result)
(results, key= x: x.pct_from_52w_high, reverse=)
VCP Pattern Detector
class VCPDetector:
"""
Volatility Contraction Pattern detection.
The tighter the contractions, the more explosive the breakout.
"""
def __init__(self,
min_contractions: int = 2,
max_first_contraction: float = 0.35,
contraction_ratio: float = 0.6):
self.min_contractions = min_contractions
self.max_first_contraction = max_first_contraction
self.contraction_ratio = contraction_ratio
def detect_vcp(self, df: pd.DataFrame) -> VCPResult:
"""
Detect VCP pattern in price data.
"""
close = df['close']
high = df['high']
low = df['low']
volume = df['volume']
lookback = 60
recent_high_idx = high.iloc[-lookback:].idxmax()
recent_high = high.loc[recent_high_idx]
contractions = self.find_contractions(df, recent_high_idx)
if len(contractions) < self.min_contractions:
return VCPResult(valid=False, reason="Insufficient contractions")
if not .validate_contraction_depths(contractions):
VCPResult(valid=, reason=)
.validate_volume_pattern(df, recent_high_idx):
VCPResult(valid=, reason=)
pivot = .calculate_pivot(df, contractions)
tightness = contractions[-][]
VCPResult(
valid=,
contractions=contractions,
pivot_price=pivot,
tightness_pct=tightness * ,
base_length_days=(df.index[-] - df.index[recent_high_idx]).days,
volume_dry_up=.calculate_volume_dryup(df, recent_high_idx)
)
() -> []:
high = df[]
low = df[]
contractions = []
current_high = high.loc[start_idx]
subset = df.loc[start_idx:]
i =
i < (subset) - :
window = subset.iloc[i:i+]
swing_low_idx = window[].idxmin()
swing_low = window[].loc[swing_low_idx]
remaining = subset.loc[swing_low_idx:]
(remaining) < :
next_window = remaining.iloc[:]
swing_high_idx = next_window[].idxmax()
swing_high = next_window[].loc[swing_high_idx]
depth = (current_high - swing_low) / current_high
contractions.append({
: current_high,
: swing_low,
: depth,
: start_idx (contractions) == swing_high_idx,
: swing_low_idx
})
current_high = swing_high
i = subset.index.get_loc(swing_high_idx) - subset.index.get_loc(subset.index[])
i +=
contractions
() -> :
i (, (contractions)):
contractions[i][] >= contractions[i-][] * :
() -> :
volume = df[]
subset = volume.loc[start_idx:]
(subset) < :
first_half_avg = subset.iloc[:(subset)//].mean()
second_half_avg = subset.iloc[(subset)//:].mean()
second_half_avg < first_half_avg *
() -> :
contractions:
df[].iloc[-:].()
contractions[-][]
() -> :
volume = df[]
avg_volume_before = volume.loc[:start_idx].iloc[-:].mean()
recent_volume = volume.iloc[-:].mean()
(avg_volume_before - recent_volume) / avg_volume_before
Entry and Risk Management
class MinerviniTradeManager:
"""
Entry, position sizing, and risk management per Minervini rules.
"""
def __init__(self,
account_size: float,
max_risk_per_trade: float = 0.01,
max_position_pct: float = 0.25):
self.account = account_size
self.risk_per_trade = max_risk_per_trade
self.max_position = max_position_pct
def calculate_entry(self,
vcp: VCPResult,
current_price: float) -> EntryPlan:
"""
Calculate entry point and buy zone.
"""
pivot = vcp.pivot_price
buy_zone_low = pivot
buy_zone_high = pivot * 1.05
in_buy_zone = buy_zone_low <= current_price <= buy_zone_high
extended = current_price > buy_zone_high
return EntryPlan(
pivot_price=pivot,
buy_zone=(buy_zone_low, buy_zone_high),
current_price=current_price,
in_buy_zone=in_buy_zone,
extended=extended,
pct_above_pivot=(current_price - pivot) / pivot * 100
)
def calculate_stop(self,
entry_price: float,
vcp: VCPResult,
max_stop_pct: float = 0.08) -> StopPlan:
"""
Calculate stop loss based on chart structure.
Minervini: max 7-8%, but tighter if structure allows.
"""
structure_stop = vcp.contractions[-][] *
structure_stop_pct = (entry_price - structure_stop) / entry_price
fixed_stop = entry_price * ( - max_stop_pct)
structure_stop_pct <= max_stop_pct:
stop_price = structure_stop
stop_type =
:
stop_price = fixed_stop
stop_type =
StopPlan(
stop_price=stop_price,
stop_pct=(entry_price - stop_price) / entry_price * ,
stop_type=stop_type,
structure_stop=structure_stop,
fixed_stop=fixed_stop
)
() -> PositionSize:
risk_amount = .account * .risk_per_trade
risk_per_share = entry_price - stop_price
shares_by_risk = (risk_amount / risk_per_share)
max_shares = (.account * .max_position / entry_price)
final_shares = (shares_by_risk, max_shares)
PositionSize(
shares=final_shares,
position_value=final_shares * entry_price,
position_pct=final_shares * entry_price / .account * ,
risk_dollars=final_shares * risk_per_share,
risk_pct=final_shares * risk_per_share / .account * ,
limited_by= shares_by_risk < max_shares
)
() -> TradePlan:
current_price = df[].iloc[-]
entry = .calculate_entry(vcp, current_price)
stop = .calculate_stop(entry.pivot_price, vcp)
position = .calculate_position_size(entry.pivot_price, stop.stop_price)
risk = entry.pivot_price - stop.stop_price
target_1 = entry.pivot_price + (risk * )
target_2 = entry.pivot_price + (risk * )
target_3 = entry.pivot_price *
TradePlan(
symbol=symbol,
entry=entry,
stop=stop,
position=position,
targets={
: target_1,
: target_2,
: target_3
},
risk_reward_ratio=,
breakout_volume_required=df[].rolling().mean().iloc[-] *
)
Sell Rules
class MinerviniSellRules:
"""
Minervini's selling discipline: protect gains, cut losses.
"""
def check_sell_signals(self,
trade: ActiveTrade,
df: pd.DataFrame) -> List[SellSignal]:
"""
Check all sell rules and return triggered signals.
"""
signals = []
current_price = df['close'].iloc[-1]
if current_price <= trade.stop_price:
signals.append(SellSignal(
type='STOP_LOSS',
priority=1,
action='SELL_ALL',
reason=f'Price {current_price:.2f} hit stop {trade.stop_price:.2f}'
))
if self.detect_climax_top(df, trade):
signals.append(SellSignal(
type='CLIMAX_TOP',
priority=2,
action='SELL_HALF',
reason='Climactic price/volume action'
))
if self.check_50ma_break(df, trade):
signals.append(SellSignal(
type='50MA_BREAK',
priority=3,
action='SELL_HALF',
reason='Closed below 50-day MA after extended move'
))
.detect_lower_low(df):
signals.append(SellSignal(
=,
priority=,
action=,
reason=
))
.check_stalled_trade(trade, current_price):
signals.append(SellSignal(
=,
priority=,
action=,
reason=
))
(signals, key= x: x.priority)
() -> :
close = df[]
volume = df[]
daily_return = close.pct_change().iloc[-]
avg_return = close.pct_change().iloc[-:].mean()
current_volume = volume.iloc[-]
avg_volume = volume.rolling().mean().iloc[-]
is_climax = (daily_return > avg_return * ) (current_volume > avg_volume * )
current_gain = (close.iloc[-] - trade.entry_price) / trade.entry_price
is_climax current_gain >
() -> :
close = df[]
ma_50 = close.rolling().mean()
current_price = close.iloc[-]
current_50ma = ma_50.iloc[-]
max_extension = ((close.iloc[-:] - ma_50.iloc[-:]) / ma_50.iloc[-:]).()
current_price < current_50ma max_extension >
() -> :
high = df[]
low = df[]
recent_high_1 = high.iloc[-:-].()
recent_high_2 = high.iloc[-:].()
recent_low_1 = low.iloc[-:-].()
recent_low_2 = low.iloc[-:].()
lower_high = recent_high_2 < recent_high_1
lower_low = recent_low_2 < recent_low_1
lower_high lower_low
() -> :
days_held = (datetime.now() - trade.entry_date).days
gain_pct = (current_price - trade.entry_price) / trade.entry_price
days_held > max_stall_days gain_pct <
Mental Model
Minervini approaches swing trading by asking:
- Is it Stage 2? If not, skip it entirely
- Is there a proper base? VCP or constructive pattern
- Where's the pivot? Specific entry point with defined risk
- What's my risk? Stop before entry, always
- Am I early or late? Only buy in the buy zone, never extended
The Trade Checklist
□ Stock passes ALL 8 trend template criteria
□ VCP or proper base pattern identified
□ Volume declining during base (supply dried up)
□ Pivot point clearly defined
□ Entry within 5% of pivot (not extended)
□ Stop loss set (max 7-8%, tighter if possible)
□ Position sized to 1% account risk
□ Volume surge on breakout (50%+ above average)
□ RS Rating > 80 (top performers)
□ EPS growth positive and accelerating
Signature Minervini Moves
- Trend Template (8 criteria filter)
- Volatility Contraction Pattern (VCP)
- Stage 2 only (never Stage 1, 3, or 4)
- Buy at pivot, not before
- 7-8% maximum stop loss
- Sell into strength (climax tops)
- Position sizing by risk
- Volume confirmation on breakout