
Breakout Trade Planner
- 692 installs
- 2.6k repo stars
- Updated August 4, 2026
- tradermonty/claude-trading-skills
breakout-trade-planner is a Claude Code agent skill that generates Minervini-style breakout trade plans from VCP screener JSON with worst-case risk sizing and Alpaca bracket order templates for developers running systema
About
breakout-trade-planner is a Claude Code skill from tradermonty/claude-trading-skills that turns VCP screener JSON into concrete trade plans with entry, stop-loss, and target prices. A Python script `skills/breakout-trade-planner/scripts/planner.py` accepts `--input reports/vcp_screener.json` and `--account-size` values, requires no market data API keys, and sizes positions using worst-case fill prices rather than optimistic signal entries. Gate rules include `composite_score >= 70`, `risk_pct_worst <= 8%`, and breakout checks such as `distance_from_pivot <= 2%`. The planner classifies candidates into actionable, revalidation, watchlist, rejected, deferred, and constrained buckets and emits two Alpaca-compatible bracket templates per candidate: pre-place auto-trigger and post-confirm 5-minute candle verification. Developers invoke it after the VCP Screener when they need share counts, stop levels, and order JSON before execution.
- breakout-trade-planner
Breakout Trade Planner by the numbers
- 692 all-time installs (skills.sh)
- +94 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #528 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/tradermonty/claude-trading-skills --skill breakout-trade-plannerAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 692 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | August 4, 2026 |
| Repository | tradermonty/claude-trading-skills ↗ |
How do you plan breakout trades from VCP screener output?
Use breakout-trade-planner for development tasks
Who is it for?
Developers running tradermonty/claude-trading-skills who need worst-case risk sizing and Alpaca bracket templates between VCP screening and order execution.
Skip if: Discretionary traders without structured screener JSON, or investing workflows unrelated to Minervini-style breakout/VCP setups.
When should I use this skill?
User has VCP screener output and asks for entry price, stop-loss, position size, portfolio heat checks, or Alpaca bracket order templates.
What you get
Trade plans with entry/stop/target prices, share sizes, six-way candidate classification, and Alpaca pre-place/post-confirm bracket order JSON.
- Trade plan per symbol
- Alpaca bracket order templates
- Candidate classification report
By the numbers
- Enforces 8% maximum worst-case risk per trade
- Rejects breakouts more than 2% above pivot distance
- Classifies candidates into 6 outcome buckets and 2 Alpaca bracket templates each
Files
Breakout Trade Planner
Generate trade plans from VCP screener output following Mark Minervini's breakout methodology. Calculate position sizes using worst-case entry prices, enforce portfolio risk limits, and output Alpaca API-compatible order templates.
When to Use
- User has VCP screener JSON output and wants trade plans
- User asks for breakout entry/stop/target calculation
- User wants Alpaca order templates for VCP breakout candidates
- User needs position sizing with portfolio heat management
Prerequisites
- VCP screener JSON output with
schema_version: "1.0" - No API keys required (works with local JSON files)
- No external skill dependencies (position sizing is built-in)
Workflow
Step 1: Generate Trade Plans
Run the planner with VCP screener output:
python3 skills/breakout-trade-planner/scripts/plan_breakout_trades.py \
--input reports/vcp_screener_YYYY-MM-DD.json \
--account-size 100000 \
--risk-pct 0.5 \
--output-dir reports/Step 2: Review Output
Read the generated JSON and Markdown reports. Present:
1. Actionable Orders — Pre-breakout candidates with order templates 2. Revalidation — Breakout-state candidates needing live confirmation 3. Watchlist — Developing VCP candidates to monitor 4. Rejected/Deferred/Constrained — Candidates filtered by Gate or portfolio limits
Step 3: Explain Trade Plans
For each actionable order, explain:
- Entry levels (signal vs worst-case) and stop-loss placement
- R-multiple targets and reward-risk ratio
- Two execution modes: pre_place (stop-limit) vs post_confirm (limit after 5min confirmation)
- Portfolio risk contribution and cumulative heat
Minervini Gate (Filtering Criteria)
Candidates must pass ALL conditions:
| Condition | Pre-breakout | Breakout |
|---|---|---|
| valid_vcp | True | True |
| rating_band | good/strong/textbook | good/strong/textbook |
| risk_pct_worst | <= 8.0% | <= 8.0% |
| breakout_volume | — | True |
| distance_from_pivot | — | <= max_chase_pct |
| current_price | — | <= worst_entry |
CLI Parameters
| Parameter | Default | Description |
|---|---|---|
| --account-size | (required) | Account equity in dollars |
| --risk-pct | 0.5 | Base risk % per trade |
| --max-position-pct | 10.0 | Max single position % |
| --max-sector-pct | 30.0 | Max sector exposure % |
| --max-portfolio-heat-pct | 6.0 | Max total open risk % |
| --target-r-multiple | 2.0 | Take-profit R-multiple |
| --stop-buffer-pct | 1.0 | Stop buffer below contraction low |
| --max-chase-pct | 2.0 | Max chase above pivot |
| --pivot-buffer-pct | 0.1 | Pivot buffer for buy-stop trigger |
| --current-exposure-json | None | Existing portfolio exposure |
Output
breakout_trade_plan_YYYY-MM-DD_HHMMSS.json— Structured plans with order templatesbreakout_trade_plan_YYYY-MM-DD_HHMMSS.md— Human-readable report
Resources
references/minervini_entry_rules.md— Entry methodology and rules
Minervini Breakout Entry Rules
Entry Methodology
Mark Minervini's VCP breakout entry follows a strict sequence:
1. Stage 2 confirmation via Trend Template (7-10 point filter) 2. VCP pattern with contracting pullbacks and volume dry-up 3. Pivot breakout on above-average volume 4. Tight stop below last contraction low
Trade Price Derivation
signal_entry = pivot * (1 + pivot_buffer_pct / 100) # Buy-stop trigger
worst_entry = pivot * (1 + max_chase_pct / 100) # Buy-limit ceiling
stop_loss = last_contraction_low * (1 - stop_buffer_pct / 100)- Gate and sizing use worst_entry (worst-case fill price)
- R-multiples displayed for both signal and worst entries
- Take-profit always uses worst_entry base
Risk Rules
- Maximum risk per trade: 8% from worst-case entry to stop
- Default account risk: 0.5% per trade
- Portfolio heat ceiling: 6% total open risk
- Never chase > 2% above pivot
Rating-Based Sizing
| Rating Band | Score | Multiplier |
|---|---|---|
| Textbook | 90+ | 1.75x |
| Strong | 80-89 | 1.0x |
| Good | 70-79 | 0.75x |
| Developing | 60-69 | 0.0x (watch only) |
Entry Quality Checks (for breakout-monitor)
When confirming a breakout on 5-minute bars:
1. close > pivot — Bar closes above the pivot level 2. close_loc >= 0.60 — Close in upper 60% of bar range 3. RVOL >= 1.5 — Time-of-day relative volume above threshold 4. chase <= 2% — Price not more than 2% above pivot
Order Types
Pre-place Mode (stop-limit bracket)
- Place at market open, auto-triggers when price reaches pivot
- buy_stop = signal_entry, buy_limit = worst_entry
- Bracket includes stop-loss and take-profit
Post-confirm Mode (limit bracket)
- Wait for 5-min candle confirmation via breakout-monitor
- Limit buy at worst_entry after conditions verified
- Bracket includes stop-loss and take-profit
Stop-Loss Placement
- Primary: Last contraction low minus buffer (default 1%)
- Alpaca requires stop >= $0.01 below entry price
- Risk from worst_entry to stop must be <= 8%
Sources
- Minervini, M. "Trade Like a Stock Market Wizard" (2013)
- Minervini, M. "Think & Trade Like a Champion" (2017)
- TrendSpider VCP Detector public scanner implementation
- ChartMill Minervini strategy documentation
"""Alpaca order template builder for breakout trade planner.
Generates stop-limit bracket templates (pre_place) and limit bracket
templates (post_confirm) for Pre-breakout candidates. Breakout candidates
get revalidation advisories only (no order template).
"""
from __future__ import annotations
def build_pre_place_template(
symbol: str,
qty: int,
signal_entry: float,
worst_entry: float,
stop_loss: float,
take_profit: float,
time_in_force: str = "day",
) -> dict:
"""Build a stop-limit bracket order template for pre-placement.
This template is placed on the market and auto-triggers when price
reaches signal_entry (buy stop). Limit at worst_entry prevents chasing.
Raises:
ValueError: On invalid inputs.
"""
_validate_order_params(qty, signal_entry, worst_entry, stop_loss, take_profit)
return {
"execution_mode": "pre_place",
"requires_monitor_confirmation": False,
"symbol": symbol,
"qty": qty,
"side": "buy",
"type": "stop_limit",
"stop_price": signal_entry,
"limit_price": worst_entry,
"time_in_force": time_in_force,
"order_class": "bracket",
"take_profit": {"limit_price": take_profit},
"stop_loss": {"stop_price": stop_loss},
}
def build_post_confirm_template(
symbol: str,
qty: int,
worst_entry: float,
stop_loss: float,
take_profit: float,
entry_condition: dict,
time_in_force: str = "day",
) -> dict:
"""Build a limit bracket order template for post-confirmation mode.
This template is sent after the breakout-monitor confirms 5-min candle
conditions (close > pivot, close_loc >= 0.60, RVOL >= 1.5).
Raises:
ValueError: On invalid inputs.
"""
if qty <= 0:
raise ValueError(f"qty must be positive, got {qty}")
if (worst_entry - stop_loss) < 0.01:
raise ValueError(
f"stop_loss ({stop_loss}) must be >= $0.01 below worst_entry ({worst_entry})"
)
if take_profit <= worst_entry:
raise ValueError(f"take_profit ({take_profit}) must be above worst_entry ({worst_entry})")
return {
"execution_mode": "post_confirm",
"requires_monitor_confirmation": True,
"entry_condition": entry_condition,
"symbol": symbol,
"qty": qty,
"side": "buy",
"type": "limit",
"limit_price": worst_entry,
"time_in_force": time_in_force,
"order_class": "bracket",
"take_profit": {"limit_price": take_profit},
"stop_loss": {"stop_price": stop_loss},
}
def build_revalidation_advisory(
symbol: str,
pivot: float,
current_price: float,
worst_entry: float,
) -> dict:
"""Build an advisory for Breakout-state candidates (no order template).
These candidates already crossed the pivot and need live revalidation
before any order can be placed.
"""
return {
"symbol": symbol,
"plan_type": "late_breakout_revalidation",
"next_action": "revalidate live price/5min confirmation before any order",
"pivot": pivot,
"current_price": current_price,
"max_entry_price": worst_entry,
}
def build_entry_condition(
pivot: float,
close_loc_min: float = 0.60,
rvol_threshold: float = 1.5,
max_chase_pct: float = 2.0,
) -> dict:
"""Build a machine-readable entry condition for the post_confirm template."""
return {
"bar_interval": "5min",
"trigger": {"field": "close", "op": ">", "value": pivot},
"checks": [
{"field": "close_loc", "op": ">=", "value": close_loc_min},
{"field": "tod_rvol", "op": ">=", "value": rvol_threshold},
{"field": "price_vs_pivot_pct", "op": "<=", "value": max_chase_pct},
],
}
def _validate_order_params(
qty: int,
signal_entry: float,
worst_entry: float,
stop_loss: float,
take_profit: float,
) -> None:
"""Shared validation for order templates."""
if qty <= 0:
raise ValueError(f"qty must be positive, got {qty}")
if (signal_entry - stop_loss) < 0.01:
raise ValueError(
f"stop_loss ({stop_loss}) must be >= $0.01 below signal_entry ({signal_entry})"
)
if take_profit <= worst_entry:
raise ValueError(f"take_profit ({take_profit}) must be above worst_entry ({worst_entry})")
#!/usr/bin/env python3
"""Breakout Trade Planner — generate Minervini-style trade plans from VCP screener output.
Reads VCP screener JSON, applies a strict Minervini Gate, calculates position
sizes using worst-case entry prices, and outputs actionable trade plans with
Alpaca order templates (pre_place and post_confirm modes).
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path
# Add scripts dir to path for sibling imports
_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
from order_builder import (
build_entry_condition,
build_post_confirm_template,
build_pre_place_template,
build_revalidation_advisory,
)
from risk_calculator import (
calculate_position_size,
calculate_r_multiples,
calculate_risks,
derive_trade_prices,
get_rating_band,
get_sizing_multiplier,
round_price,
)
ACCEPTED_INPUT_VERSIONS = {"1.0"}
MAX_RISK_PCT = 8.0
def load_input(path: str) -> dict:
"""Load and validate VCP screener JSON."""
with open(path) as f:
data = json.load(f)
version = data.get("schema_version")
if version is None:
raise ValueError(
f"Input JSON missing 'schema_version' (expected one of {ACCEPTED_INPUT_VERSIONS})"
)
if version not in ACCEPTED_INPUT_VERSIONS:
raise ValueError(
f"Unsupported schema_version '{version}' (expected {ACCEPTED_INPUT_VERSIONS})"
)
if "results" not in data or not isinstance(data["results"], list):
raise ValueError("Input JSON missing or empty 'results' array")
return data
def load_exposure(path: str | None) -> dict:
"""Load current portfolio exposure or return defaults."""
if path and os.path.exists(path):
with open(path) as f:
return json.load(f)
return {"sector_exposure": {}, "open_risk_pct": 0.0}
REQUIRED_FIELDS = ["symbol", "sector", "price", "composite_score", "execution_state", "valid_vcp"]
BREAKOUT_EXTRA_FIELDS = [
"volume_pattern.breakout_volume_detected",
"pivot_proximity.distance_from_pivot_pct",
]
def _get_nested(d: dict, key: str):
"""Get a possibly nested field like 'vcp_pattern.pivot_price'."""
parts = key.split(".")
val = d
for p in parts:
if not isinstance(val, dict):
return None
val = val.get(p)
return val
def validate_result(result: dict) -> tuple[bool, list[str]]:
"""Validate a single VCP result has required fields.
Returns (is_valid, list_of_warnings).
"""
warnings = []
for field in REQUIRED_FIELDS:
if _get_nested(result, field) is None:
warnings.append(f"missing required field: {field}")
pivot = _get_nested(result, "vcp_pattern.pivot_price")
if pivot is None:
warnings.append("missing vcp_pattern.pivot_price")
contractions = _get_nested(result, "vcp_pattern.contractions")
if not contractions or not isinstance(contractions, list) or len(contractions) == 0:
warnings.append("missing or empty vcp_pattern.contractions")
elif _get_nested(contractions[-1], "low_price") is None:
warnings.append("missing contractions[-1].low_price")
# Warn (not fail) for Breakout-specific fields missing
state = _get_nested(result, "execution_state")
if state == "Breakout":
for field in BREAKOUT_EXTRA_FIELDS:
if _get_nested(result, field) is None:
warnings.append(f"missing Breakout field: {field}")
return len(warnings) == 0, warnings
def process_candidate(
result: dict,
args: argparse.Namespace,
cumulative_risk_pct: float,
sector_tracker: dict[str, float],
exposure: dict,
) -> dict:
"""Process a single VCP candidate through the Minervini Gate.
Returns a classified result dict with plan_type, trade_plan, etc.
"""
symbol = result["symbol"]
current_price = result["price"]
composite_score = result["composite_score"]
execution_state = result["execution_state"]
valid_vcp = result.get("valid_vcp", False)
sector = result.get("sector", "Unknown")
rating_band = get_rating_band(composite_score)
# Derive trade prices
pivot = result["vcp_pattern"]["pivot_price"]
contractions = result["vcp_pattern"]["contractions"]
last_low = contractions[-1]["low_price"]
try:
signal_entry, worst_entry, stop_loss = derive_trade_prices(
pivot,
last_low,
pivot_buffer_pct=args.pivot_buffer_pct,
max_chase_pct=args.max_chase_pct,
stop_buffer_pct=args.stop_buffer_pct,
)
except ValueError as e:
return _reject(symbol, f"Trade price derivation failed: {e}")
risk_pct_signal, risk_pct_worst = calculate_risks(signal_entry, worst_entry, stop_loss)
# Take profit (worst-entry based)
tp_worst = round_price(worst_entry + args.target_r_multiple * (worst_entry - stop_loss))
base_output = {
"symbol": symbol,
"company_name": result.get("company_name", ""),
"sector": sector,
"composite_score": composite_score,
"rating_band": rating_band,
"execution_state": execution_state,
}
# --- Pre-breakout path ---
if execution_state == "Pre-breakout":
plan_eligible = (
valid_vcp
and rating_band in ("textbook", "strong", "good")
and risk_pct_worst <= MAX_RISK_PCT
)
if not plan_eligible:
# Check watchlist eligibility
if valid_vcp and 60 <= composite_score < 70:
return _watchlist(base_output, pivot, stop_loss)
reasons = []
if not valid_vcp:
reasons.append("valid_vcp=False")
if rating_band not in ("textbook", "strong", "good"):
reasons.append(f"rating_band={rating_band}")
if risk_pct_worst > MAX_RISK_PCT:
reasons.append(f"risk_pct_worst={risk_pct_worst}%>{MAX_RISK_PCT}%")
return _reject(symbol, "; ".join(reasons))
return _build_actionable(
base_output,
args,
signal_entry,
worst_entry,
stop_loss,
risk_pct_signal,
risk_pct_worst,
tp_worst,
pivot,
cumulative_risk_pct,
sector_tracker,
exposure,
)
# --- Breakout path ---
if execution_state == "Breakout":
breakout_volume = _get_nested(result, "volume_pattern.breakout_volume_detected") or False
distance = _get_nested(result, "pivot_proximity.distance_from_pivot_pct")
if distance is None:
return _reject(symbol, "missing distance_from_pivot_pct for Breakout")
plan_eligible = (
valid_vcp
and rating_band in ("textbook", "strong", "good")
and risk_pct_worst <= MAX_RISK_PCT
and breakout_volume
and distance <= args.max_chase_pct
and current_price <= worst_entry
)
if plan_eligible:
advisory = build_revalidation_advisory(symbol, pivot, current_price, worst_entry)
advisory.update(base_output)
advisory["decision_code"] = "REVALIDATION_BREAKOUT"
advisory["risk_pct_worst"] = risk_pct_worst
return {"classification": "revalidation", "data": advisory}
# Breakout candidates do not go to watchlist — they already crossed pivot
reasons = []
if not valid_vcp:
reasons.append("valid_vcp=False")
if not breakout_volume:
reasons.append("no breakout volume")
if distance is not None and distance > args.max_chase_pct:
reasons.append(f"distance={distance}%>{args.max_chase_pct}%")
if current_price > worst_entry:
reasons.append(f"price={current_price}>worst_entry={worst_entry}")
if risk_pct_worst > MAX_RISK_PCT:
reasons.append(f"risk_pct_worst={risk_pct_worst}%>{MAX_RISK_PCT}%")
return _reject(symbol, "; ".join(reasons) if reasons else "ineligible Breakout")
# --- Watchlist path ---
if (
valid_vcp
and execution_state in ("Pre-breakout", "Early-post-breakout")
and 60 <= composite_score < 70
):
return _watchlist(base_output, pivot, stop_loss)
# --- Reject ---
return _reject(symbol, f"state={execution_state}, score={composite_score}")
def _build_actionable(
base: dict,
args,
signal_entry,
worst_entry,
stop_loss,
risk_pct_signal,
risk_pct_worst,
tp_worst,
pivot,
cumulative_risk_pct,
sector_tracker,
exposure,
):
"""Build an actionable order with trade plan and order templates."""
sector = base["sector"]
rating_band = base["rating_band"]
multiplier = get_sizing_multiplier(rating_band)
current_sector_exp = exposure.get("sector_exposure", {}).get(sector, 0.0)
current_sector_exp += sector_tracker.get(sector, 0.0)
sizing = calculate_position_size(
worst_entry=worst_entry,
stop_loss=stop_loss,
account_size=args.account_size,
base_risk_pct=args.risk_pct,
sizing_multiplier=multiplier,
max_position_pct=args.max_position_pct,
max_sector_pct=args.max_sector_pct,
current_sector_exposure=current_sector_exp,
)
if sizing["shares"] == 0:
constraint = sizing.get("binding_constraint", "unknown")
return {
"classification": "constrained",
"data": {"symbol": base["symbol"], "reason": f"0 shares: {constraint}"},
}
risk_dollars = sizing["risk_dollars"]
risk_pct_of_account = risk_dollars / args.account_size * 100
new_cumulative = cumulative_risk_pct + risk_pct_of_account
if new_cumulative > args.max_portfolio_heat_pct:
return {
"classification": "deferred",
"data": {
"symbol": base["symbol"],
"reason": f"Portfolio heat ceiling: {new_cumulative:.2f}% > {args.max_portfolio_heat_pct}%",
},
}
# Build entry condition and order templates
entry_cond = build_entry_condition(
pivot=pivot,
max_chase_pct=args.max_chase_pct,
)
pre_place = build_pre_place_template(
symbol=base["symbol"],
qty=sizing["shares"],
signal_entry=signal_entry,
worst_entry=worst_entry,
stop_loss=stop_loss,
take_profit=tp_worst,
)
post_confirm = build_post_confirm_template(
symbol=base["symbol"],
qty=sizing["shares"],
worst_entry=worst_entry,
stop_loss=stop_loss,
take_profit=tp_worst,
entry_condition=entry_cond,
)
# Valid for today if market is open (weekday), otherwise next trading day
today = datetime.now().date()
if today.weekday() < 5: # Monday-Friday: valid today
valid_date = today
else: # Weekend: next Monday
valid_date = today + timedelta(days=7 - today.weekday())
result = {
**base,
"plan_type": "pending_breakout",
"decision_code": "ACTIONABLE_PREBREAKOUT",
"decision_reason": (
f"valid_vcp && state=Pre-breakout && risk_worst={risk_pct_worst}% <= {MAX_RISK_PCT}%"
),
"plan_valid_for_session": str(valid_date),
"trade_plan": {
"signal_entry": signal_entry,
"worst_entry": worst_entry,
"stop_loss_price": stop_loss,
"risk_per_share": round(worst_entry - stop_loss, 2),
"risk_pct_signal": risk_pct_signal,
"risk_pct_worst": risk_pct_worst,
"r_multiples_signal": calculate_r_multiples(signal_entry, stop_loss),
"r_multiples_worst": calculate_r_multiples(worst_entry, stop_loss),
"target_price": tp_worst,
"reward_risk_ratio": args.target_r_multiple,
"sizing_multiplier": multiplier,
"effective_risk_pct": sizing["effective_risk_pct"],
"shares": sizing["shares"],
"position_value": sizing["position_value"],
"risk_dollars": risk_dollars,
"cumulative_risk_pct": round(new_cumulative, 2),
"binding_constraint": sizing["binding_constraint"],
},
"order_templates": {
"pre_place": pre_place,
"post_confirm": post_confirm,
},
}
return {"classification": "actionable", "data": result, "risk_pct": risk_pct_of_account}
def _watchlist(base: dict, pivot: float, stop_loss: float) -> dict:
return {
"classification": "watchlist",
"data": {
**base,
"plan_type": "watchlist",
"pivot_price": pivot,
"stop_loss_price": stop_loss,
"alert_trigger": f"Price crosses above ${pivot:.2f} on 1.5x RVOL",
},
}
def _reject(symbol: str, reason: str) -> dict:
return {
"classification": "rejected",
"data": {"symbol": symbol, "reason": reason},
}
def generate_plans(data: dict, args: argparse.Namespace) -> dict:
"""Main pipeline: filter, score, size, classify all candidates."""
exposure = load_exposure(args.current_exposure_json)
results = data["results"]
# Sort by composite_score descending (highest priority first)
results_sorted = sorted(results, key=lambda r: r.get("composite_score", 0), reverse=True)
actionable = []
revalidation = []
watchlist = []
rejected = []
deferred = []
constrained = []
warnings = []
cumulative_risk_pct = exposure.get("open_risk_pct", 0.0)
sector_tracker: dict[str, float] = {}
for result in results_sorted:
is_valid, warns = validate_result(result)
if not is_valid:
symbol = result.get("symbol", "UNKNOWN")
for w in warns:
warnings.append({"symbol": symbol, "code": "MISSING_FIELD", "message": w})
rejected.append({"symbol": symbol, "reason": f"validation: {'; '.join(warns)}"})
continue
classified = process_candidate(result, args, cumulative_risk_pct, sector_tracker, exposure)
cls = classified["classification"]
if cls == "actionable":
actionable.append(classified["data"])
cumulative_risk_pct += classified["risk_pct"]
sector = classified["data"]["sector"]
pos_pct = classified["data"]["trade_plan"]["position_value"] / args.account_size * 100
sector_tracker[sector] = sector_tracker.get(sector, 0.0) + pos_pct
elif cls == "revalidation":
revalidation.append(classified["data"])
elif cls == "watchlist":
watchlist.append(classified["data"])
elif cls == "deferred":
deferred.append(classified["data"])
elif cls == "constrained":
constrained.append(classified["data"])
else:
rejected.append(classified["data"])
total_risk_dollars = sum(a["trade_plan"]["risk_dollars"] for a in actionable)
total_risk_pct = total_risk_dollars / args.account_size * 100 if args.account_size > 0 else 0
total_position = sum(a["trade_plan"]["position_value"] for a in actionable)
return {
"schema_version": "1.0",
"generated_at": datetime.now().isoformat(timespec="seconds"),
"parameters": {
"account_size": args.account_size,
"base_risk_pct": args.risk_pct,
"max_position_pct": args.max_position_pct,
"max_sector_pct": args.max_sector_pct,
"max_portfolio_heat_pct": args.max_portfolio_heat_pct,
"target_r_multiple": args.target_r_multiple,
"stop_buffer_pct": args.stop_buffer_pct,
"max_chase_pct": args.max_chase_pct,
"pivot_buffer_pct": args.pivot_buffer_pct,
"current_exposure": exposure,
},
"input_metadata": {
"source_file": args.input,
"screener_generated_at": _get_nested(data, "metadata.generated_at"),
"candidates_in_file": len(data["results"]),
"screener_total_candidates": _get_nested(data, "summary.total"),
"input_scope": "top_n_only",
},
"summary": {
"actionable_count": len(actionable),
"revalidation_count": len(revalidation),
"watchlist_count": len(watchlist),
"rejected_count": len(rejected),
"deferred_count": len(deferred),
"constrained_count": len(constrained),
"total_risk_dollars": round(total_risk_dollars, 2),
"total_risk_pct": round(total_risk_pct, 2),
"total_position_value": round(total_position, 2),
},
"actionable_orders": actionable,
"revalidation": revalidation,
"watchlist": watchlist,
"rejected": rejected,
"deferred": deferred,
"constrained": constrained,
"warnings": warnings,
}
def generate_markdown(plans: dict) -> str:
"""Generate human-readable markdown from plans."""
lines = [
"# Breakout Trade Plan",
f"**Generated:** {plans['generated_at']}",
f"**Account Size:** ${plans['parameters']['account_size']:,.0f} | "
f"**Base Risk:** {plans['parameters']['base_risk_pct']}%",
"",
"## Summary",
f"- Actionable: {plans['summary']['actionable_count']}",
f"- Revalidation: {plans['summary']['revalidation_count']}",
f"- Watchlist: {plans['summary']['watchlist_count']}",
f"- Rejected: {plans['summary']['rejected_count']}",
f"- Total Risk: ${plans['summary']['total_risk_dollars']:,.2f} "
f"({plans['summary']['total_risk_pct']:.2f}%)",
"",
]
if plans["actionable_orders"]:
lines.append("## Actionable Orders\n")
for i, order in enumerate(plans["actionable_orders"], 1):
tp = order["trade_plan"]
lines.extend(
[
f"### {i}. {order['symbol']} — {order.get('company_name', '')}",
f"**Rating:** {order['rating_band']} ({order['composite_score']}) | "
f"**State:** {order['execution_state']}",
"",
"| Parameter | Value |",
"|-----------|-------|",
f"| Signal Entry | ${tp['signal_entry']:.2f} |",
f"| Worst Entry | ${tp['worst_entry']:.2f} |",
f"| Stop Loss | ${tp['stop_loss_price']:.2f} |",
f"| Risk (worst) | {tp['risk_pct_worst']:.1f}% |",
f"| Target ({tp['reward_risk_ratio']}R) | ${tp['target_price']:.2f} |",
f"| Shares | {tp['shares']} |",
f"| Position Value | ${tp['position_value']:,.2f} |",
f"| Risk $ | ${tp['risk_dollars']:,.2f} |",
"",
]
)
if plans["revalidation"]:
lines.append("## Revalidation (Breakout — needs live confirmation)\n")
for r in plans["revalidation"]:
lines.append(
f"- **{r['symbol']}** — pivot ${r['pivot']:.2f}, "
f"current ${r['current_price']:.2f}\n"
)
if plans["watchlist"]:
lines.append("## Watchlist\n")
lines.append("| Symbol | Score | Alert |")
lines.append("|--------|-------|-------|")
for w in plans["watchlist"]:
lines.append(
f"| {w['symbol']} | {w['composite_score']} | {w.get('alert_trigger', '')} |"
)
lines.append("")
lines.append("\n---\n*Disclaimer: Not investment advice.*\n")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Generate breakout trade plans from VCP screener output"
)
parser.add_argument("--input", required=True, help="VCP screener JSON path")
parser.add_argument("--account-size", type=float, required=True, help="Account equity ($)")
parser.add_argument("--risk-pct", type=float, default=0.5, help="Base risk %% per trade")
parser.add_argument("--max-position-pct", type=float, default=10.0)
parser.add_argument("--max-sector-pct", type=float, default=30.0)
parser.add_argument("--max-portfolio-heat-pct", type=float, default=6.0)
parser.add_argument("--target-r-multiple", type=float, default=2.0)
parser.add_argument("--stop-buffer-pct", type=float, default=1.0)
parser.add_argument("--max-chase-pct", type=float, default=2.0)
parser.add_argument("--pivot-buffer-pct", type=float, default=0.1)
parser.add_argument("--current-exposure-json", default=None)
parser.add_argument("--output-dir", default="reports/")
args = parser.parse_args()
data = load_input(args.input)
plans = generate_plans(data, args)
os.makedirs(args.output_dir, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
json_file = os.path.join(args.output_dir, f"breakout_trade_plan_{ts}.json")
with open(json_file, "w") as f:
json.dump(plans, f, indent=2, default=str)
print(f"JSON plan saved to: {json_file}")
md_file = os.path.join(args.output_dir, f"breakout_trade_plan_{ts}.md")
with open(md_file, "w") as f:
f.write(generate_markdown(plans))
print(f"Markdown plan saved to: {md_file}")
print(
f"\nActionable: {plans['summary']['actionable_count']} | "
f"Revalidation: {plans['summary']['revalidation_count']} | "
f"Watchlist: {plans['summary']['watchlist_count']}"
)
if __name__ == "__main__":
main()
"""Risk calculator for breakout trade planner.
Derives trade prices (signal entry, worst-case entry, stop-loss) from VCP
screener data, calculates risk metrics, R-multiples, rating bands, and
position sizing. Self-contained — no external skill dependencies.
"""
from __future__ import annotations
def round_price(price: float) -> float:
"""Round price to Alpaca tick size.
>= $1.00: 2 decimal places
< $1.00: 4 decimal places
"""
if price >= 1.0:
return round(price, 2)
return round(price, 4)
def derive_trade_prices(
pivot: float,
last_contraction_low: float,
pivot_buffer_pct: float = 0.1,
max_chase_pct: float = 2.0,
stop_buffer_pct: float = 1.0,
) -> tuple[float, float, float]:
"""Derive signal entry, worst-case entry, and stop-loss from VCP data.
Args:
pivot: Pivot price (high of last contraction).
last_contraction_low: Low of the last contraction (stop reference).
pivot_buffer_pct: % above pivot for buy-stop trigger.
max_chase_pct: % above pivot for buy-limit ceiling.
stop_buffer_pct: % below contraction low for stop-loss.
Returns:
(signal_entry, worst_entry, stop_loss) — all rounded to Alpaca ticks.
Raises:
ValueError: If inputs are non-positive or stop >= signal entry.
"""
if pivot <= 0:
raise ValueError(f"pivot must be positive, got {pivot}")
if last_contraction_low <= 0:
raise ValueError(f"last_contraction_low must be positive, got {last_contraction_low}")
if pivot_buffer_pct > max_chase_pct:
raise ValueError(
f"pivot_buffer_pct ({pivot_buffer_pct}) must be <= max_chase_pct ({max_chase_pct})"
)
signal_entry = round_price(pivot * (1 + pivot_buffer_pct / 100))
worst_entry = round_price(pivot * (1 + max_chase_pct / 100))
stop_loss = round_price(last_contraction_low * (1 - stop_buffer_pct / 100))
if stop_loss >= signal_entry:
raise ValueError(f"stop_loss ({stop_loss}) must be below signal_entry ({signal_entry})")
return signal_entry, worst_entry, stop_loss
def calculate_risks(
signal_entry: float,
worst_entry: float,
stop_loss: float,
) -> tuple[float, float]:
"""Calculate risk percentages from both entry scenarios.
Returns:
(risk_pct_signal, risk_pct_worst)
"""
risk_pct_signal = (signal_entry - stop_loss) / signal_entry * 100
risk_pct_worst = (worst_entry - stop_loss) / worst_entry * 100
return round(risk_pct_signal, 2), round(risk_pct_worst, 2)
def calculate_r_multiples(
entry: float,
stop_loss: float,
multiples: tuple[float, ...] = (1.0, 2.0, 3.0),
) -> dict[str, float]:
"""Calculate R-multiple price targets.
R = entry - stop_loss.
nR target = entry + n * R.
"""
r = entry - stop_loss
return {f"{m}R": round_price(entry + m * r) for m in multiples}
def get_rating_band(composite_score: float) -> str:
"""Map composite score to rating band (numeric, no string comparison)."""
if composite_score >= 90:
return "textbook"
if composite_score >= 80:
return "strong"
if composite_score >= 70:
return "good"
if composite_score >= 60:
return "developing"
return "weak"
SIZING_MULTIPLIER: dict[str, float] = {
"textbook": 1.75,
"strong": 1.0,
"good": 0.75,
"developing": 0.0,
"weak": 0.0,
}
def get_sizing_multiplier(rating_band: str) -> float:
"""Get position sizing multiplier for a rating band."""
return SIZING_MULTIPLIER.get(rating_band, 0.0)
def calculate_position_size(
worst_entry: float,
stop_loss: float,
account_size: float,
base_risk_pct: float,
sizing_multiplier: float,
max_position_pct: float = 10.0,
max_sector_pct: float = 30.0,
current_sector_exposure: float = 0.0,
) -> dict:
"""Calculate position size with portfolio constraints.
Uses worst_entry as the entry price for conservative sizing.
Self-contained fixed-fractional sizing with constraint checks.
Returns:
Dict with shares, position_value, risk_dollars, binding_constraint.
"""
effective_risk_pct = base_risk_pct * sizing_multiplier
if effective_risk_pct <= 0:
return {
"shares": 0,
"position_value": 0.0,
"risk_dollars": 0.0,
"effective_risk_pct": 0.0,
"binding_constraint": "sizing_multiplier_zero",
}
# Fixed fractional: shares = dollar_risk / risk_per_share
risk_per_share = worst_entry - stop_loss
dollar_risk = account_size * effective_risk_pct / 100
risk_shares = int(dollar_risk / risk_per_share)
# Portfolio constraints
candidates = [risk_shares]
constraints: list[dict] = []
binding: str | None = None
# Max position % constraint
max_by_pos = int(account_size * max_position_pct / 100 / worst_entry)
constraints.append(
{
"type": "max_position_pct",
"limit": max_position_pct,
"max_shares": max_by_pos,
"binding": False,
}
)
candidates.append(max_by_pos)
# Max sector % constraint
remaining_pct = max_sector_pct - current_sector_exposure
remaining_dollars = max(0.0, remaining_pct / 100 * account_size)
max_by_sector = max(0, int(remaining_dollars / worst_entry))
constraints.append(
{
"type": "max_sector_pct",
"limit": max_sector_pct,
"current": current_sector_exposure,
"max_shares": max_by_sector,
"binding": False,
}
)
candidates.append(max_by_sector)
final_shares = max(0, min(candidates))
# Identify binding constraint
for c in constraints:
if c["max_shares"] == final_shares and final_shares < risk_shares:
c["binding"] = True
binding = c["type"]
position_value = round(final_shares * worst_entry, 2)
risk_dollars = round(final_shares * risk_per_share, 2)
return {
"shares": final_shares,
"position_value": position_value,
"risk_dollars": risk_dollars,
"effective_risk_pct": round(effective_risk_pct, 4),
"binding_constraint": binding,
"constraints_applied": constraints,
}
"""Test configuration for breakout-trade-planner."""
import sys
from pathlib import Path
# Add scripts directory to path
_SCRIPTS_DIR = Path(__file__).resolve().parents[1]
if str(_SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPTS_DIR))
"""Tests for order_builder module."""
import pytest
from order_builder import (
build_entry_condition,
build_post_confirm_template,
build_pre_place_template,
build_revalidation_advisory,
)
class TestBuildPrePlaceTemplate:
def test_stop_limit_bracket_structure(self):
order = build_pre_place_template(
symbol="PWR",
qty=10,
signal_entry=583.32,
worst_entry=595.40,
stop_loss=516.81,
take_profit=717.57,
)
assert order["symbol"] == "PWR"
assert order["qty"] == 10
assert order["side"] == "buy"
assert order["type"] == "stop_limit"
assert order["stop_price"] == 583.32
assert order["limit_price"] == 595.40
assert order["order_class"] == "bracket"
assert order["take_profit"]["limit_price"] == 717.57
assert order["stop_loss"]["stop_price"] == 516.81
assert order["time_in_force"] == "day"
def test_qty_zero_raises(self):
with pytest.raises(ValueError, match="qty must be positive"):
build_pre_place_template(
symbol="X",
qty=0,
signal_entry=100.0,
worst_entry=102.0,
stop_loss=95.0,
take_profit=110.0,
)
def test_stop_too_close_to_entry_raises(self):
# stop must be >= $0.01 below signal_entry
with pytest.raises(ValueError, match="stop_loss.*must be.*below"):
build_pre_place_template(
symbol="X",
qty=10,
signal_entry=100.0,
worst_entry=102.0,
stop_loss=100.0,
take_profit=110.0,
)
def test_take_profit_below_worst_raises(self):
with pytest.raises(ValueError, match="take_profit.*must be above"):
build_pre_place_template(
symbol="X",
qty=10,
signal_entry=100.0,
worst_entry=102.0,
stop_loss=95.0,
take_profit=101.0,
)
class TestBuildPostConfirmTemplate:
def test_limit_bracket_structure(self):
condition = build_entry_condition(pivot=583.73)
order = build_post_confirm_template(
symbol="PWR",
qty=10,
worst_entry=595.40,
stop_loss=516.81,
take_profit=717.57,
entry_condition=condition,
)
assert order["type"] == "limit"
assert order["limit_price"] == 595.40
assert order["order_class"] == "bracket"
assert order["execution_mode"] == "post_confirm"
assert order["requires_monitor_confirmation"] is True
assert order["entry_condition"]["bar_interval"] == "5min"
assert order["take_profit"]["limit_price"] == 717.57
assert order["stop_loss"]["stop_price"] == 516.81
def test_qty_zero_raises(self):
with pytest.raises(ValueError, match="qty must be positive"):
build_post_confirm_template(
symbol="X",
qty=0,
worst_entry=102.0,
stop_loss=95.0,
take_profit=110.0,
entry_condition={},
)
class TestBuildRevalidationAdvisory:
def test_advisory_structure(self):
advisory = build_revalidation_advisory(
symbol="ANET",
pivot=141.77,
current_price=145.07,
worst_entry=144.60,
)
assert advisory["symbol"] == "ANET"
assert advisory["plan_type"] == "late_breakout_revalidation"
assert advisory["next_action"].startswith("revalidate")
assert advisory["pivot"] == 141.77
assert advisory["current_price"] == 145.07
assert advisory["max_entry_price"] == 144.60
def test_no_alpaca_order_fields(self):
advisory = build_revalidation_advisory(
symbol="X",
pivot=100.0,
current_price=103.0,
worst_entry=102.0,
)
assert "qty" not in advisory
assert "type" not in advisory
assert "order_class" not in advisory
class TestBuildEntryCondition:
def test_machine_readable_format(self):
cond = build_entry_condition(pivot=319.52)
assert cond["bar_interval"] == "5min"
assert cond["trigger"]["field"] == "close"
assert cond["trigger"]["op"] == ">"
assert cond["trigger"]["value"] == 319.52
assert len(cond["checks"]) == 3
def test_custom_thresholds(self):
cond = build_entry_condition(
pivot=100.0,
close_loc_min=0.70,
rvol_threshold=2.0,
max_chase_pct=1.5,
)
close_loc_check = cond["checks"][0]
assert close_loc_check["value"] == 0.70
rvol_check = cond["checks"][1]
assert rvol_check["value"] == 2.0
chase_check = cond["checks"][2]
assert chase_check["value"] == 1.5
"""Tests for plan_breakout_trades module."""
from __future__ import annotations
import argparse
import json
import os
import tempfile
import pytest
from plan_breakout_trades import (
generate_plans,
load_input,
process_candidate,
validate_result,
)
def _make_args(**overrides) -> argparse.Namespace:
defaults = {
"input": "test.json",
"account_size": 100_000,
"risk_pct": 0.5,
"max_position_pct": 10.0,
"max_sector_pct": 30.0,
"max_portfolio_heat_pct": 6.0,
"target_r_multiple": 2.0,
"stop_buffer_pct": 1.0,
"max_chase_pct": 2.0,
"pivot_buffer_pct": 0.1,
"current_exposure_json": None,
"output_dir": "/tmp/test_plans",
}
defaults.update(overrides)
return argparse.Namespace(**defaults)
def _make_vcp_result(
symbol: str = "TEST",
score: float = 85.0,
state: str = "Pre-breakout",
valid_vcp: bool = True,
pivot: float = 100.0,
last_low: float = 95.0,
sector: str = "Technology",
price: float = 98.0,
breakout_volume: bool = False,
distance_from_pivot: float = -2.0,
) -> dict:
return {
"symbol": symbol,
"company_name": f"{symbol} Inc.",
"sector": sector,
"price": price,
"market_cap": 50_000_000_000,
"composite_score": score,
"rating": "Strong VCP",
"execution_state": state,
"valid_vcp": valid_vcp,
"entry_ready": False,
"vcp_pattern": {
"pivot_price": pivot,
"contractions": [
{"label": "T1", "high_price": 105.0, "low_price": 92.0, "depth_pct": 12.4},
{"label": "T2", "high_price": pivot, "low_price": last_low, "depth_pct": 5.0},
],
"atr_value": 2.5,
},
"volume_pattern": {
"breakout_volume_detected": breakout_volume,
"avg_volume_50d": 1_000_000,
"dry_up_ratio": 0.5,
},
"pivot_proximity": {
"stop_loss_price": last_low * 0.99,
"risk_pct": 5.0,
"distance_from_pivot_pct": distance_from_pivot,
},
"trend_template": {"score": 100},
"relative_strength": {"rs_percentile": 80},
}
def _make_input_data(results: list[dict]) -> dict:
return {
"schema_version": "1.0",
"metadata": {"generated_at": "2026-04-12 17:35:47"},
"results": results,
"summary": {"total": len(results)},
"sector_distribution": {},
}
class TestLoadInput:
def test_missing_schema_version_raises(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"results": []}, f)
f.flush()
with pytest.raises(ValueError, match="schema_version"):
load_input(f.name)
os.unlink(f.name)
def test_wrong_schema_version_raises(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump({"schema_version": "99.0", "results": []}, f)
f.flush()
with pytest.raises(ValueError, match="Unsupported"):
load_input(f.name)
os.unlink(f.name)
def test_valid_input_loads(self):
data = _make_input_data([_make_vcp_result()])
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
json.dump(data, f)
f.flush()
loaded = load_input(f.name)
assert len(loaded["results"]) == 1
os.unlink(f.name)
class TestValidateResult:
def test_valid_result(self):
result = _make_vcp_result()
is_valid, warnings = validate_result(result)
assert is_valid
assert warnings == []
def test_missing_symbol(self):
result = _make_vcp_result()
del result["symbol"]
is_valid, warnings = validate_result(result)
assert not is_valid
assert any("symbol" in w for w in warnings)
def test_missing_contractions(self):
result = _make_vcp_result()
result["vcp_pattern"]["contractions"] = []
is_valid, warnings = validate_result(result)
assert not is_valid
class TestMinerviniGate:
def test_prebreakout_strong_vcp_actionable(self):
result = _make_vcp_result(score=85.0, state="Pre-breakout", valid_vcp=True)
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "actionable"
assert classified["data"]["plan_type"] == "pending_breakout"
assert classified["data"]["decision_code"] == "ACTIONABLE_PREBREAKOUT"
def test_prebreakout_risk_worst_over_8_rejected(self):
# pivot=100, last_low=88 -> stop=87.12, worst=102 -> risk=14.58%
result = _make_vcp_result(score=85.0, last_low=88.0)
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "rejected"
assert "risk_pct_worst" in classified["data"]["reason"]
def test_prebreakout_invalid_vcp_rejected(self):
result = _make_vcp_result(valid_vcp=False)
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "rejected"
def test_prebreakout_developing_watchlist(self):
result = _make_vcp_result(score=65.0, valid_vcp=True)
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "watchlist"
def test_developing_invalid_vcp_rejected(self):
result = _make_vcp_result(score=65.0, valid_vcp=False)
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "rejected"
def test_breakout_with_volume_revalidation(self):
result = _make_vcp_result(
score=85.0,
state="Breakout",
valid_vcp=True,
breakout_volume=True,
distance_from_pivot=1.5,
price=101.0,
)
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "revalidation"
assert classified["data"]["plan_type"] == "late_breakout_revalidation"
def test_breakout_no_volume_rejected(self):
result = _make_vcp_result(
score=85.0,
state="Breakout",
valid_vcp=True,
breakout_volume=False,
distance_from_pivot=1.5,
price=101.0,
)
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "rejected"
def test_breakout_price_above_worst_rejected(self):
result = _make_vcp_result(
score=85.0,
state="Breakout",
valid_vcp=True,
breakout_volume=True,
distance_from_pivot=1.5,
price=110.0,
)
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "rejected"
def test_extended_state_rejected(self):
result = _make_vcp_result(state="Extended")
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "rejected"
def test_overextended_state_rejected(self):
result = _make_vcp_result(state="Overextended")
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "rejected"
def test_breakout_developing_does_not_watchlist(self):
"""Breakout candidates must not go to watchlist — they already crossed pivot."""
result = _make_vcp_result(
score=65.0,
state="Breakout",
valid_vcp=True,
breakout_volume=True,
distance_from_pivot=1.5,
price=101.0,
)
args = _make_args()
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "rejected"
assert classified["classification"] != "watchlist"
class TestPortfolioConstraints:
def test_heat_ceiling_defers(self):
result = _make_vcp_result(score=85.0)
args = _make_args(max_portfolio_heat_pct=0.01) # Tiny ceiling
classified = process_candidate(
result, args, 0.0, {}, {"sector_exposure": {}, "open_risk_pct": 0}
)
assert classified["classification"] == "deferred"
def test_sector_constraint_constrains(self):
result = _make_vcp_result(score=85.0, sector="Technology")
args = _make_args(max_sector_pct=5.0)
exposure = {"sector_exposure": {"Technology": 4.9}, "open_risk_pct": 0}
classified = process_candidate(result, args, 0.0, {}, exposure)
assert classified["classification"] == "constrained"
class TestGeneratePlans:
def test_empty_results(self):
data = _make_input_data([])
args = _make_args()
plans = generate_plans(data, args)
assert plans["schema_version"] == "1.0"
assert plans["summary"]["actionable_count"] == 0
def test_score_order_processing(self):
r1 = _make_vcp_result(symbol="HIGH", score=90.0)
r2 = _make_vcp_result(symbol="LOW", score=75.0)
data = _make_input_data([r2, r1]) # Lower score first in input
args = _make_args()
plans = generate_plans(data, args)
if len(plans["actionable_orders"]) >= 2:
assert plans["actionable_orders"][0]["symbol"] == "HIGH"
def test_validation_failure_creates_warning(self):
bad = {"symbol": "BAD"} # Missing required fields
data = _make_input_data([bad])
args = _make_args()
plans = generate_plans(data, args)
assert len(plans["warnings"]) > 0
assert plans["summary"]["rejected_count"] == 1
def test_actionable_has_order_templates(self):
result = _make_vcp_result(score=85.0)
data = _make_input_data([result])
args = _make_args()
plans = generate_plans(data, args)
assert plans["summary"]["actionable_count"] == 1
order = plans["actionable_orders"][0]
assert "pre_place" in order["order_templates"]
assert "post_confirm" in order["order_templates"]
assert order["order_templates"]["pre_place"]["type"] == "stop_limit"
assert order["order_templates"]["post_confirm"]["type"] == "limit"
def test_input_metadata_populated(self):
data = _make_input_data([_make_vcp_result()])
args = _make_args()
plans = generate_plans(data, args)
meta = plans["input_metadata"]
assert meta["candidates_in_file"] == 1
assert meta["input_scope"] == "top_n_only"
"""Tests for risk_calculator module."""
import pytest
from risk_calculator import (
calculate_position_size,
calculate_r_multiples,
calculate_risks,
derive_trade_prices,
get_rating_band,
get_sizing_multiplier,
round_price,
)
class TestRoundPrice:
def test_above_1_dollar_rounds_to_2_decimals(self):
assert round_price(150.123) == 150.12
def test_above_1_dollar_rounds_up(self):
assert round_price(150.126) == 150.13
def test_below_1_dollar_rounds_to_4_decimals(self):
assert round_price(0.12345) == 0.1235
def test_exactly_1_dollar(self):
assert round_price(1.0) == 1.0
def test_boundary_just_below_1(self):
assert round_price(0.9999) == 0.9999
class TestDeriveTradePrices:
def test_default_params(self):
# pivot=100, last_low=95, defaults: buf=0.1%, chase=2.0%, stop_buf=1.0%
signal, worst, stop = derive_trade_prices(100.0, 95.0)
assert signal == 100.10 # 100 * 1.001
assert worst == 102.00 # 100 * 1.02
assert stop == 94.05 # 95 * 0.99
def test_custom_params(self):
signal, worst, stop = derive_trade_prices(
200.0, 190.0, pivot_buffer_pct=0.2, max_chase_pct=3.0, stop_buffer_pct=2.0
)
assert signal == 200.40 # 200 * 1.002
assert worst == 206.00 # 200 * 1.03
assert stop == 186.20 # 190 * 0.98
def test_zero_pivot_raises(self):
with pytest.raises(ValueError, match="pivot must be positive"):
derive_trade_prices(0, 95.0)
def test_zero_low_raises(self):
with pytest.raises(ValueError, match="last_contraction_low must be positive"):
derive_trade_prices(100.0, 0)
def test_stop_above_entry_raises(self):
# pivot=10, last_low=15 → stop=14.85, signal=10.01 → stop > signal
with pytest.raises(ValueError, match="stop_loss.*must be below"):
derive_trade_prices(10.0, 15.0)
class TestCalculateRisks:
def test_basic_risk_calculation(self):
signal_risk, worst_risk = calculate_risks(100.10, 102.00, 94.05)
assert signal_risk == 6.04 # (100.10 - 94.05) / 100.10 * 100
assert worst_risk == 7.79 # (102.00 - 94.05) / 102.00 * 100
def test_worst_is_always_larger(self):
signal_risk, worst_risk = calculate_risks(50.05, 51.00, 47.00)
assert worst_risk > signal_risk
def test_both_pass_gate_but_worst_is_tighter(self):
# last_low=95 -> signal=100.10, worst=102.00, stop=94.05
# signal_risk=6.04%, worst_risk=7.79% -> both < 8%
signal, worst, stop = derive_trade_prices(100.0, 95.0)
signal_risk, worst_risk = calculate_risks(signal, worst, stop)
assert signal_risk < 8.0
assert worst_risk < 8.0
assert worst_risk > signal_risk
def test_worst_risk_fails_gate(self):
# pivot=100, last_low=91 -> stop=90.09, worst=102 -> risk=11.68%
signal, worst, stop = derive_trade_prices(100.0, 91.0)
_, worst_risk = calculate_risks(signal, worst, stop)
assert worst_risk > 8.0
class TestCalculateRMultiples:
def test_default_multiples(self):
result = calculate_r_multiples(100.0, 95.0)
# R = 5.0
assert result["1.0R"] == 105.0
assert result["2.0R"] == 110.0
assert result["3.0R"] == 115.0
def test_custom_multiples(self):
result = calculate_r_multiples(100.0, 95.0, multiples=(0.5, 1.5))
assert result["0.5R"] == 102.5
assert result["1.5R"] == 107.5
def test_worst_entry_multiples_larger_targets(self):
# Worst entry is higher, so R is larger, targets are further
signal_r = calculate_r_multiples(100.10, 94.05)
worst_r = calculate_r_multiples(102.00, 94.05)
assert worst_r["2.0R"] > signal_r["2.0R"]
class TestGetRatingBand:
def test_textbook(self):
assert get_rating_band(92.5) == "textbook"
assert get_rating_band(90.0) == "textbook"
def test_strong(self):
assert get_rating_band(85.0) == "strong"
assert get_rating_band(80.0) == "strong"
def test_good(self):
assert get_rating_band(75.0) == "good"
assert get_rating_band(70.0) == "good"
def test_developing(self):
assert get_rating_band(65.0) == "developing"
assert get_rating_band(60.0) == "developing"
def test_weak(self):
assert get_rating_band(50.0) == "weak"
assert get_rating_band(0.0) == "weak"
class TestGetSizingMultiplier:
def test_textbook_175(self):
assert get_sizing_multiplier("textbook") == 1.75
def test_strong_100(self):
assert get_sizing_multiplier("strong") == 1.0
def test_good_075(self):
assert get_sizing_multiplier("good") == 0.75
def test_developing_zero(self):
assert get_sizing_multiplier("developing") == 0.0
def test_unknown_zero(self):
assert get_sizing_multiplier("unknown") == 0.0
class TestCalculatePositionSize:
def test_basic_sizing(self):
result = calculate_position_size(
worst_entry=102.00,
stop_loss=94.05,
account_size=100_000,
base_risk_pct=0.5,
sizing_multiplier=1.0,
)
# effective_risk = 0.5%, dollar_risk = 500, risk_per_share = 7.95
# shares = int(500 / 7.95) = 62
assert result["shares"] == 62
assert result["effective_risk_pct"] == 0.5
assert result["risk_dollars"] > 0
assert result["position_value"] > 0
def test_sizing_multiplier_applied(self):
base = calculate_position_size(
worst_entry=100.0,
stop_loss=95.0,
account_size=100_000,
base_risk_pct=0.5,
sizing_multiplier=1.0,
max_position_pct=100.0,
)
textbook = calculate_position_size(
worst_entry=100.0,
stop_loss=95.0,
account_size=100_000,
base_risk_pct=0.5,
sizing_multiplier=1.75,
max_position_pct=100.0,
)
assert textbook["shares"] > base["shares"]
def test_zero_multiplier_returns_zero_shares(self):
result = calculate_position_size(
worst_entry=100.0,
stop_loss=95.0,
account_size=100_000,
base_risk_pct=0.5,
sizing_multiplier=0.0,
)
assert result["shares"] == 0
assert result["binding_constraint"] == "sizing_multiplier_zero"
def test_sector_constraint_limits_shares(self):
unconstrained = calculate_position_size(
worst_entry=100.0,
stop_loss=95.0,
account_size=100_000,
base_risk_pct=1.0,
sizing_multiplier=1.0,
)
constrained = calculate_position_size(
worst_entry=100.0,
stop_loss=95.0,
account_size=100_000,
base_risk_pct=1.0,
sizing_multiplier=1.0,
max_sector_pct=5.0,
current_sector_exposure=4.5,
)
assert constrained["shares"] < unconstrained["shares"]
Related skills
How it compares
Pick breakout-trade-planner over the VCP screener alone when you need worst-case position sizing, portfolio heat gates, and Alpaca bracket JSON—not just candidate discovery.
FAQ
What input does breakout-trade-planner require?
breakout-trade-planner expects VCP screener JSON, typically `reports/vcp_screener.json`, passed to `planner.py` with `--input` and an `--account-size` dollar value for position sizing.
What risk limits does breakout-trade-planner enforce?
breakout-trade-planner rejects trades when `risk_pct_worst` exceeds 8% and breakout setups when price is more than 2% above pivot. It also requires `composite_score >= 70` and valid VCP flags.
Does breakout-trade-planner need market API keys?
breakout-trade-planner performs local calculations on screener JSON and does not require market data API keys. It outputs Alpaca-compatible bracket order templates for downstream execution.