position-sizer
Calculate risk-based position sizes for long stock trades. Use when user asks about position sizing, how many shares to buy, risk per trade, Kelly criterion, ATR-based sizing, or portfolio risk allocation. Supports stop-loss distance calculation, volatility scaling, and sector concentration checks.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Install command
npx @skill-hub/cli install tradermonty-claude-trading-skills-position-sizer
Repository
Skill path: skills/position-sizer
Calculate risk-based position sizes for long stock trades. Use when user asks about position sizing, how many shares to buy, risk per trade, Kelly criterion, ATR-based sizing, or portfolio risk allocation. Supports stop-loss distance calculation, volatility scaling, and sector concentration checks.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: tradermonty.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install position-sizer into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/tradermonty/claude-trading-skills before adding position-sizer to shared team environments
- Use position-sizer for development workflows
Works across
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
name: position-sizer
description: Calculate risk-based position sizes for long stock trades. Use when user asks about position sizing, how many shares to buy, risk per trade, Kelly criterion, ATR-based sizing, or portfolio risk allocation. Supports stop-loss distance calculation, volatility scaling, and sector concentration checks.
---
# Position Sizer
## Overview
Calculate the optimal number of shares to buy for a long stock trade based on risk management principles. Supports three sizing methods:
- **Fixed Fractional**: Risk a fixed percentage of account equity per trade (default: 1%)
- **ATR-Based**: Use Average True Range to set volatility-adjusted stop distances
- **Kelly Criterion**: Calculate mathematically optimal risk allocation from historical win/loss statistics
All methods apply portfolio constraints (max position %, max sector %) and output a final recommended share count with full risk breakdown.
## When to Use
- User asks "how many shares should I buy?"
- User wants to calculate position size for a specific trade setup
- User mentions risk per trade, stop-loss sizing, or portfolio allocation
- User asks about Kelly Criterion or ATR-based position sizing
- User wants to check if a position fits within portfolio concentration limits
## Prerequisites
- No API keys required
- Python 3.9+ with standard library only
## Workflow
### Step 1: Gather Trade Parameters
Collect from the user:
- **Required**: Account size (total equity)
- **Mode A (Fixed Fractional)**: Entry price, stop price, risk percentage (default 1%)
- **Mode B (ATR-Based)**: Entry price, ATR value, ATR multiplier (default 2.0x), risk percentage
- **Mode C (Kelly Criterion)**: Win rate, average win, average loss; optionally entry and stop for share calculation
- **Optional constraints**: Max position % of account, max sector %, current sector exposure
If the user provides a stock ticker but not specific prices, use available tools to look up the current price and suggest entry/stop levels based on technical analysis.
### Step 2: Execute Position Sizer Script
Run the position sizing calculation:
```bash
# Fixed Fractional (most common)
python3 skills/position-sizer/scripts/position_sizer.py \
--account-size 100000 \
--entry 155 \
--stop 148.50 \
--risk-pct 1.0 \
--output-dir reports/
# ATR-Based
python3 skills/position-sizer/scripts/position_sizer.py \
--account-size 100000 \
--entry 155 \
--atr 3.20 \
--atr-multiplier 2.0 \
--risk-pct 1.0 \
--output-dir reports/
# Kelly Criterion (budget mode - no entry)
python3 skills/position-sizer/scripts/position_sizer.py \
--account-size 100000 \
--win-rate 0.55 \
--avg-win 2.5 \
--avg-loss 1.0 \
--output-dir reports/
# Kelly Criterion (shares mode - with entry/stop)
python3 skills/position-sizer/scripts/position_sizer.py \
--account-size 100000 \
--entry 155 \
--stop 148.50 \
--win-rate 0.55 \
--avg-win 2.5 \
--avg-loss 1.0 \
--output-dir reports/
```
### Step 3: Load Methodology Reference
Read `references/sizing_methodologies.md` to provide context on the chosen method, risk guidelines, and portfolio constraint best practices.
### Step 4: Calculate Multiple Scenarios
If the user has not specified a single method, run multiple scenarios for comparison:
- Fixed Fractional at 0.5%, 1.0%, and 1.5% risk
- ATR-based at 1.5x, 2.0x, and 3.0x multipliers
- Present a comparison table showing shares, position value, and dollar risk for each
### Step 5: Apply Portfolio Constraints and Determine Final Size
Add constraints if the user has portfolio context:
```bash
python3 skills/position-sizer/scripts/position_sizer.py \
--account-size 100000 \
--entry 155 \
--stop 148.50 \
--risk-pct 1.0 \
--max-position-pct 10 \
--max-sector-pct 30 \
--current-sector-exposure 22 \
--output-dir reports/
```
Explain which constraint is binding and why it limits the position.
### Step 6: Generate Position Report
Present the final recommendation including:
- Method used and rationale
- Exact share count and position value
- Dollar risk and percentage of account
- Stop-loss price
- Any binding constraints
- Risk management reminders (portfolio heat, loss-cutting discipline)
## Output Format
### JSON Report
```json
{
"schema_version": "1.0",
"mode": "shares",
"parameters": {
"entry_price": 155.0,
"account_size": 100000,
"stop_price": 148.50,
"risk_pct": 1.0
},
"calculations": {
"fixed_fractional": {
"method": "fixed_fractional",
"shares": 153,
"risk_per_share": 6.50,
"dollar_risk": 1000.0,
"stop_price": 148.50
},
"atr_based": null,
"kelly": null
},
"constraints_applied": [],
"final_recommended_shares": 153,
"final_position_value": 23715.0,
"final_risk_dollars": 994.50,
"final_risk_pct": 0.99,
"binding_constraint": null
}
```
### Markdown Report
Generated automatically alongside the JSON report. Contains:
- Parameters summary
- Calculation details for the active method
- Constraints analysis (if any)
- Final recommendation with shares, value, and risk
Reports are saved to `reports/` with filenames `position_sizer_YYYY-MM-DD_HHMMSS.json` and `.md`.
## Resources
- `references/sizing_methodologies.md`: Comprehensive guide to Fixed Fractional, ATR-based, and Kelly Criterion methods with examples, comparison table, and risk management principles
- `scripts/position_sizer.py`: Main calculation script (CLI interface)
## Key Principles
1. **Survival first**: Position sizing is about surviving losing streaks, not maximizing winners
2. **The 1% rule**: Default to 1% risk per trade; never exceed 2% without exceptional reason
3. **Round down**: Always round shares down to whole numbers (never round up)
4. **Strictest constraint wins**: When multiple limits apply, the tightest one determines final size
5. **Half Kelly**: Never use full Kelly in practice; half Kelly captures 75% of growth with far less risk
6. **Portfolio heat**: Total open risk should not exceed 6-8% of account equity
7. **Asymmetry of losses**: A 50% loss requires a 100% gain to recover; size accordingly
---
## Referenced Files
> The following files are referenced in this skill and included for context.
### references/sizing_methodologies.md
```markdown
# Position Sizing Methodologies
## Overview
Position sizing determines how many shares to buy on each trade. Correct sizing is the single most important factor in long-term portfolio survival. A great stock pick with bad sizing can destroy an account; a mediocre pick with proper sizing preserves capital for the next opportunity.
This reference covers three primary methods: Fixed Fractional, ATR-based, and Kelly Criterion. Each has distinct strengths and ideal use cases.
---
## Fixed Fractional Method (Percentage Risk)
### Concept
Risk a fixed percentage of the account on every trade. The most widely used method among professional traders, popularized by Van Tharp and applied rigorously by Mark Minervini and William O'Neil.
### Formula
```
risk_per_share = entry_price - stop_price
dollar_risk = account_size * risk_pct / 100
shares = int(dollar_risk / risk_per_share)
```
### Standard Risk Levels
| Risk % | Trader Profile | Notes |
|--------|---------------|-------|
| 0.25-0.50% | Conservative / large account | Institutional-grade risk |
| 0.50-1.00% | Experienced swing trader | Minervini recommended range |
| 1.00-1.50% | Active trader, proven edge | Standard for tested systems |
| 1.50-2.00% | Aggressive, high win-rate | Maximum for most strategies |
| > 2.00% | Dangerous | Ruin risk increases rapidly |
### Example
- Account: $100,000
- Entry: $155.00, Stop: $148.50
- Risk per share: $6.50
- At 1% risk: $1,000 / $6.50 = **153 shares**
- Position value: $23,715 (23.7% of account)
### When to Use
- Default method for most swing and position trades
- When you have a clear technical stop level (support, moving average, prior low)
- When trading a system with established risk parameters
### Minervini / O'Neil Integration
Mark Minervini recommends:
- Risk no more than 1% per trade during the early stages of a rally
- Tighten to 0.5% after consecutive losses
- Use a "progressive exposure" model: start with half position, add on confirmation
- Maximum portfolio heat (total open risk): 6-8%
William O'Neil recommends:
- Cut losses at 7-8% below purchase price (hard maximum)
- Preferred loss cut: 3-5% for experienced traders
- Use a "follow-through day" to confirm market direction before increasing exposure
---
## ATR-Based Method (Volatility Sizing)
### Concept
Use the Average True Range (ATR) to set stop distance, automatically adjusting position size to a stock's volatility. Originated with the Turtle Traders (Richard Dennis, 1983).
### Formula
```
stop_distance = atr * atr_multiplier
stop_price = entry_price - stop_distance
risk_per_share = stop_distance
dollar_risk = account_size * risk_pct / 100
shares = int(dollar_risk / risk_per_share)
```
### ATR Multiplier Guidance
| Multiplier | Stop Width | Style |
|-----------|-----------|-------|
| 1.0x | Tight | Day trading, very short-term |
| 1.5x | Moderate-tight | Swing trading, 2-5 day holds |
| 2.0x | Standard | Default for most swing trades (Turtle Traders) |
| 2.5x | Wide | Position trading, 2-8 week holds |
| 3.0x | Very wide | Trend following, multi-month holds |
### Example
- Account: $100,000, Risk: 1%
- Entry: $155.00, ATR(14) = $3.20, Multiplier = 2.0x
- Stop distance: $6.40, Stop: $148.60
- Dollar risk: $1,000
- Shares: int($1,000 / $6.40) = **156 shares**
### When to Use
- When you want volatility-adjusted sizing across different stocks
- When a stock lacks clear support/resistance for a discrete stop
- For systematic/mechanical trading systems
- When comparing positions across stocks with different price ranges and volatilities
### Advantages Over Fixed Stop
1. Low-volatility stocks get larger positions (tighter stop relative to price)
2. High-volatility stocks get smaller positions (wider stop protects against noise)
3. Normalizes risk across the portfolio regardless of stock price
---
## Kelly Criterion
### Concept
The Kelly Criterion calculates the mathematically optimal fraction of capital to risk, given known win rate and payoff ratio. Developed by John L. Kelly Jr. (1956) at Bell Labs.
### Formula
```
R = avg_win / avg_loss (payoff ratio)
kelly_pct = W - (1 - W) / R (full Kelly percentage)
half_kelly = kelly_pct / 2 (practical recommendation)
```
Where W = historical win rate (0 to 1).
### Full Kelly vs. Half Kelly
**Full Kelly** maximizes long-term geometric growth but produces extreme volatility. Drawdowns of 50%+ are common. No professional fund uses full Kelly.
**Half Kelly** achieves approximately 75% of the theoretical growth rate with dramatically lower drawdowns. This is the standard recommendation for real trading.
| Metric | Full Kelly | Half Kelly | Quarter Kelly |
|--------|-----------|-----------|---------------|
| Growth rate | 100% | ~75% | ~50% |
| Max drawdown | Severe (50%+) | Moderate (25-35%) | Mild (15-20%) |
| Practical use | Never | Aggressive | Conservative |
### Example
- Win rate: 55%, Avg win: $2.50, Avg loss: $1.00
- R = 2.5 / 1.0 = 2.5
- Kelly = 0.55 - 0.45 / 2.5 = 0.55 - 0.18 = 0.37 = **37%**
- Half Kelly = **18.5%**
- On $100,000 account: risk budget = $18,500
### Negative Expectancy
When the Kelly formula produces a negative value, the system has negative expected value. The Kelly percentage is floored at 0%, meaning "do not trade this system."
Example:
- Win rate: 30%, Avg win: $1.00, Avg loss: $1.50
- R = 1.0 / 1.5 = 0.667
- Kelly = 0.30 - 0.70 / 0.667 = 0.30 - 1.05 = **-0.75** -> floored to **0%**
### Two Modes of Use
1. **Budget Mode** (no entry price): Returns a recommended risk budget as a percentage of account. Useful for capital allocation planning before identifying specific entries.
2. **Shares Mode** (with entry and stop): Converts the half-Kelly budget into a specific share count using the entry/stop distance.
### When to Use
- When you have reliable historical win rate and payoff statistics (100+ trades minimum)
- For portfolio-level capital allocation across multiple strategies
- As a ceiling check: "Am I risking more than Kelly suggests?"
- Not suitable for discretionary traders without track records
---
## Portfolio Constraints
### Maximum Position Size
Limit any single position to a percentage of account value:
```
max_shares = int(account_size * max_position_pct / 100 / entry_price)
```
**Guidelines:**
- 5-10%: Conservative (diversified portfolio, 10-20 positions)
- 10-15%: Moderate (concentrated portfolio, 7-10 positions)
- 15-25%: Aggressive (high-conviction portfolio, 4-7 positions)
- > 25%: Speculative (not recommended for most traders)
### Sector Concentration
Limit total exposure to any single sector:
```
remaining_pct = max_sector_pct - current_sector_exposure
remaining_dollars = remaining_pct / 100 * account_size
max_shares = int(remaining_dollars / entry_price)
```
**Guidelines:**
- Individual stock: 5-10% of portfolio
- Single sector: 25-30% maximum
- Correlated positions: Treat as a single exposure
### Position Count and Diversification
| Positions | Diversification | Notes |
|-----------|----------------|-------|
| 1-4 | Very concentrated | High volatility, requires high conviction |
| 5-10 | Focused | Sweet spot for active traders |
| 10-20 | Diversified | Diminishing returns above 15 |
| 20+ | Over-diversified | Dilutes edge, approaches index performance |
Research shows that beyond 20 uncorrelated positions, additional diversification benefit is minimal. For active traders, 5-10 positions with proper sizing often produces better risk-adjusted returns than 20+ positions with diluted conviction.
### Binding Constraint Logic
When multiple constraints apply, the strictest (minimum share count) wins. The position sizer identifies which constraint is "binding" so the trader understands what limits the position.
Priority order:
1. Risk-based shares (from Fixed Fractional, ATR, or Kelly)
2. Max position % limit
3. Max sector % limit
4. Final = minimum of all candidates
---
## Method Comparison
| Feature | Fixed Fractional | ATR-Based | Kelly Criterion |
|---------|-----------------|-----------|-----------------|
| Input needed | Entry, stop, risk % | Entry, ATR, multiplier, risk % | Win rate, avg win/loss |
| Adjusts for volatility | No | Yes | No (uses historical stats) |
| Requires track record | No | No | Yes (100+ trades) |
| Best for | Discretionary trades | Systematic/mechanical | Capital allocation |
| Complexity | Low | Medium | Medium |
| Typical use | Primary sizing | Primary sizing | Ceiling check / allocation |
| Stop determined by | Chart analysis | ATR calculation | External (chart or ATR) |
### Recommended Workflow
1. **Start with Fixed Fractional** at 1% risk for new strategies or market conditions
2. **Switch to ATR-based** when comparing opportunities across different volatility profiles
3. **Use Kelly as a ceiling** after accumulating 100+ trade records
4. **Always apply constraints** (position limit, sector limit) as a final filter
5. **Reduce risk** after consecutive losses (Minervini's "progressive exposure" in reverse)
---
## Risk Management Principles
### The 1% Rule
Never risk more than 1% of account equity on a single trade. This ensures survival through inevitable losing streaks:
- 10 consecutive losses at 1% = 9.6% drawdown (recoverable)
- 10 consecutive losses at 5% = 40.1% drawdown (devastating)
- 10 consecutive losses at 10% = 65.1% drawdown (account-threatening)
### Portfolio Heat
Total open risk across all positions should not exceed 6-8% of account:
```
portfolio_heat = sum(shares_i * risk_per_share_i) / account_size * 100
```
If portfolio heat exceeds 8%, do not add new positions until existing trades are moved to breakeven or closed.
### Asymmetry of Losses
Losses require disproportionately larger gains to recover:
| Loss | Gain to Recover |
|------|----------------|
| 10% | 11.1% |
| 20% | 25.0% |
| 30% | 42.9% |
| 50% | 100.0% |
| 75% | 300.0% |
This asymmetry is why position sizing and loss cutting are more important than stock selection.
```
### scripts/position_sizer.py
```python
"""Position sizer for long stock trades.
Calculates risk-based position sizes using Fixed Fractional, ATR-based,
or Kelly Criterion methods. Applies portfolio constraints (max position %,
max sector %) and outputs a final recommended share count.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from dataclasses import dataclass
from datetime import datetime
@dataclass
class SizingParameters:
account_size: float
entry_price: float | None = None
stop_price: float | None = None
risk_pct: float | None = None
atr: float | None = None
atr_multiplier: float = 2.0
win_rate: float | None = None
avg_win: float | None = None
avg_loss: float | None = None
max_position_pct: float | None = None
max_sector_pct: float | None = None
sector: str | None = None
current_sector_exposure: float = 0.0
def validate_parameters(params: SizingParameters) -> None:
"""Validate input parameters. Raise ValueError on invalid input."""
if params.account_size <= 0:
raise ValueError("account_size must be positive")
if params.entry_price is not None and params.entry_price <= 0:
raise ValueError("entry_price must be positive")
if params.stop_price is not None and params.entry_price is not None:
if params.stop_price >= params.entry_price:
raise ValueError("stop_price must be below entry_price for long trades")
if params.risk_pct is not None and params.risk_pct <= 0:
raise ValueError("risk_pct must be positive")
if params.atr is not None and params.atr <= 0:
raise ValueError("atr must be positive")
if params.win_rate is not None:
if params.win_rate <= 0 or params.win_rate > 1.0:
raise ValueError("win_rate must be between 0 (exclusive) and 1.0 (inclusive)")
if params.avg_win is not None and params.avg_win <= 0:
raise ValueError("avg_win must be positive")
if params.avg_loss is not None and params.avg_loss <= 0:
raise ValueError("avg_loss must be positive")
def calculate_fixed_fractional(params: SizingParameters) -> dict:
"""Fixed fractional position sizing.
Calculates shares based on a fixed percentage of account risked per trade.
risk_per_share = entry_price - stop_price
dollar_risk = account_size * risk_pct / 100
shares = int(dollar_risk / risk_per_share)
"""
risk_per_share = params.entry_price - params.stop_price
dollar_risk = params.account_size * params.risk_pct / 100
shares = int(dollar_risk / risk_per_share)
return {
"method": "fixed_fractional",
"shares": shares,
"risk_per_share": round(risk_per_share, 2),
"dollar_risk": round(dollar_risk, 2),
"stop_price": params.stop_price,
}
def calculate_atr_based(params: SizingParameters) -> dict:
"""ATR-based position sizing.
Uses Average True Range to determine stop distance:
stop_distance = atr * atr_multiplier
stop_price = entry_price - stop_distance
"""
stop_distance = params.atr * params.atr_multiplier
stop_price = round(params.entry_price - stop_distance, 2)
risk_per_share = stop_distance
dollar_risk = params.account_size * params.risk_pct / 100
shares = int(dollar_risk / risk_per_share)
return {
"method": "atr_based",
"shares": shares,
"risk_per_share": round(risk_per_share, 2),
"dollar_risk": round(dollar_risk, 2),
"stop_price": stop_price,
"atr": params.atr,
"atr_multiplier": params.atr_multiplier,
}
def calculate_kelly(params: SizingParameters) -> dict:
"""Kelly Criterion calculation.
Kelly % = W - (1-W)/R
where W = win_rate, R = avg_win / avg_loss
Half-Kelly = kelly_pct / 2 (recommended conservative amount)
Negative expectancy floors at 0%.
"""
w = params.win_rate
r = params.avg_win / params.avg_loss
kelly_pct = w - (1 - w) / r
kelly_pct = max(0.0, kelly_pct) * 100 # Convert to percentage, floor at 0
half_kelly_pct = kelly_pct / 2
return {
"method": "kelly",
"kelly_pct": round(kelly_pct, 2),
"half_kelly_pct": round(half_kelly_pct, 2),
}
def apply_constraints(shares: int, params: SizingParameters) -> tuple[int, list[dict], str | None]:
"""Apply portfolio constraints and return (final_shares, constraints, binding).
Evaluates max position % and max sector % constraints, then returns
the minimum of all candidate share counts (strictest constraint wins).
"""
constraints: list[dict] = []
candidates = [shares]
binding: str | None = None
if params.max_position_pct is not None and params.entry_price:
max_by_pos = int(params.account_size * params.max_position_pct / 100 / params.entry_price)
constraints.append(
{
"type": "max_position_pct",
"limit": params.max_position_pct,
"max_shares": max_by_pos,
"binding": False,
}
)
candidates.append(max_by_pos)
if params.max_sector_pct is not None and params.entry_price:
remaining_pct = params.max_sector_pct - params.current_sector_exposure
remaining_dollars = remaining_pct / 100 * params.account_size
max_by_sector = max(0, int(remaining_dollars / params.entry_price))
constraints.append(
{
"type": "max_sector_pct",
"limit": params.max_sector_pct,
"current": params.current_sector_exposure,
"max_shares": max_by_sector,
"binding": False,
}
)
candidates.append(max_by_sector)
final = max(0, min(candidates))
# Identify binding constraint
for c in constraints:
if c["max_shares"] == final and final < shares:
c["binding"] = True
binding = c["type"]
return final, constraints, binding
def calculate_position(params: SizingParameters) -> dict:
"""Main calculation entry point.
Determines mode (budget or shares), runs the appropriate sizing method,
applies constraints, and returns the full result dictionary.
"""
validate_parameters(params)
is_kelly_mode = params.win_rate is not None
has_entry = params.entry_price is not None
result: dict = {
"schema_version": "1.0",
"parameters": {},
}
if is_kelly_mode and not has_entry:
# Budget mode: Kelly without entry price
kelly = calculate_kelly(params)
result["mode"] = "budget"
result["parameters"] = {
"win_rate": params.win_rate,
"avg_win": params.avg_win,
"avg_loss": params.avg_loss,
"account_size": params.account_size,
}
result["calculations"] = {
"kelly": kelly,
"fixed_fractional": None,
"atr_based": None,
}
budget = params.account_size * kelly["half_kelly_pct"] / 100
result["recommended_risk_budget"] = round(budget, 2)
result["recommended_risk_budget_pct"] = kelly["half_kelly_pct"]
result["note"] = "To calculate shares, re-run with --entry and --stop"
return result
# Shares mode
result["mode"] = "shares"
result["parameters"] = {
"entry_price": params.entry_price,
"account_size": params.account_size,
}
calculations: dict = {
"fixed_fractional": None,
"atr_based": None,
"kelly": None,
}
risk_shares = 0
if is_kelly_mode:
kelly = calculate_kelly(params)
calculations["kelly"] = kelly
# Use half-kelly budget to determine shares
budget = params.account_size * kelly["half_kelly_pct"] / 100
if params.stop_price:
risk_per_share = params.entry_price - params.stop_price
risk_shares = int(budget / risk_per_share)
result["parameters"]["stop_price"] = params.stop_price
else:
risk_shares = int(budget / params.entry_price)
elif params.atr is not None:
atr_result = calculate_atr_based(params)
calculations["atr_based"] = atr_result
risk_shares = atr_result["shares"]
result["parameters"]["stop_price"] = atr_result["stop_price"]
result["parameters"]["risk_pct"] = params.risk_pct
else:
ff_result = calculate_fixed_fractional(params)
calculations["fixed_fractional"] = ff_result
risk_shares = ff_result["shares"]
result["parameters"]["stop_price"] = params.stop_price
result["parameters"]["risk_pct"] = params.risk_pct
result["calculations"] = calculations
# Apply constraints
final_shares, constraints, binding = apply_constraints(risk_shares, params)
result["constraints_applied"] = constraints
result["final_recommended_shares"] = final_shares
result["final_position_value"] = round(final_shares * params.entry_price, 2)
# Calculate actual risk for final shares
if params.stop_price:
risk_per_share = params.entry_price - params.stop_price
result["final_risk_dollars"] = round(final_shares * risk_per_share, 2)
result["final_risk_pct"] = round(
final_shares * risk_per_share / params.account_size * 100, 2
)
elif params.atr:
risk_per_share = params.atr * params.atr_multiplier
result["final_risk_dollars"] = round(final_shares * risk_per_share, 2)
result["final_risk_pct"] = round(
final_shares * risk_per_share / params.account_size * 100, 2
)
else:
# Kelly shares mode without stop/ATR: risk per share is undefined
result["final_risk_dollars"] = None
result["final_risk_pct"] = None
result["risk_note"] = "Stop-loss not defined. Specify --stop to calculate risk dollars."
result["binding_constraint"] = binding
return result
def generate_markdown_report(result: dict) -> str:
"""Generate a markdown report from the calculation result."""
lines = [
"# Position Sizing Report",
"**Generated:** {}".format(datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
"**Mode:** {}".format(result["mode"]),
"",
"## Parameters",
]
for k, v in result.get("parameters", {}).items():
lines.append(f"- **{k}:** {v}")
lines.append("")
if result["mode"] == "budget":
lines.append("## Kelly Criterion")
kelly = result["calculations"]["kelly"]
lines.append("- Full Kelly: {}%".format(kelly["kelly_pct"]))
lines.append("- Half Kelly (recommended): {}%".format(kelly["half_kelly_pct"]))
lines.append(
"- **Recommended Risk Budget:** ${:,.2f}".format(result["recommended_risk_budget"])
)
lines.append(" ({}% of account)".format(result["recommended_risk_budget_pct"]))
lines.append("")
lines.append("*{}*".format(result["note"]))
else:
lines.append("## Calculations")
for method, calc in result.get("calculations", {}).items():
if calc:
lines.append("### {}".format(method.replace("_", " ").title()))
for k, v in calc.items():
if k != "method":
lines.append(f"- {k}: {v}")
lines.append("")
if result.get("constraints_applied"):
lines.append("## Constraints")
for c in result["constraints_applied"]:
binding_label = " **[BINDING]**" if c.get("binding") else ""
lines.append(
"- {}: limit={}%, max_shares={}{}".format(
c["type"], c["limit"], c["max_shares"], binding_label
)
)
lines.append("")
lines.append("## Final Recommendation")
lines.append("- **Shares:** {}".format(result["final_recommended_shares"]))
lines.append("- **Position Value:** ${:,.2f}".format(result["final_position_value"]))
if result.get("final_risk_dollars") is not None:
lines.append(
"- **Risk:** ${:,.2f} ({}%)".format(
result["final_risk_dollars"], result["final_risk_pct"]
)
)
if result.get("risk_note"):
lines.append("- **Note:** {}".format(result["risk_note"]))
if result.get("binding_constraint"):
lines.append("- **Binding Constraint:** {}".format(result["binding_constraint"]))
return "\n".join(lines) + "\n"
def build_parser() -> argparse.ArgumentParser:
"""Build the argument parser for CLI usage."""
parser = argparse.ArgumentParser(
description=("Calculate risk-based position sizes for long stock trades")
)
parser.add_argument(
"--account-size",
type=float,
required=True,
help="Total account value in dollars",
)
parser.add_argument("--entry", type=float, help="Entry price per share")
parser.add_argument("--stop", type=float, help="Stop-loss price per share")
parser.add_argument(
"--risk-pct",
type=float,
help="Risk percentage per trade (e.g. 1.0 for 1%%)",
)
parser.add_argument("--atr", type=float, help="Average True Range value")
parser.add_argument(
"--atr-multiplier",
type=float,
default=2.0,
help="ATR multiplier for stop distance (default: 2.0)",
)
parser.add_argument(
"--win-rate",
type=float,
help="Historical win rate (0-1) for Kelly criterion",
)
parser.add_argument(
"--avg-win",
type=float,
help="Average win amount for Kelly criterion",
)
parser.add_argument(
"--avg-loss",
type=float,
help="Average loss amount for Kelly criterion",
)
parser.add_argument(
"--max-position-pct",
type=float,
help="Maximum single position as %% of account",
)
parser.add_argument(
"--max-sector-pct",
type=float,
help="Maximum sector exposure as %% of account",
)
parser.add_argument("--sector", type=str, help="Sector name for concentration check")
parser.add_argument(
"--current-sector-exposure",
type=float,
default=0.0,
help="Current sector exposure as %% of account",
)
parser.add_argument(
"--output-dir",
type=str,
default="reports/",
help="Output directory for reports",
)
return parser
def main() -> None:
"""CLI entry point."""
parser = build_parser()
args = parser.parse_args()
# Validate mutual exclusivity
if args.risk_pct is not None and args.win_rate is not None:
parser.error("position sizing requires either risk-pct mode OR kelly mode, not both")
# Validate required combinations
if args.win_rate is not None:
if args.avg_win is None or args.avg_loss is None:
parser.error("Kelly mode requires --win-rate, --avg-win, and --avg-loss")
elif args.risk_pct is not None:
if args.entry is None:
parser.error("Risk-pct mode requires --entry")
if args.stop is None and args.atr is None:
parser.error("Risk-pct mode requires either --stop or --atr")
else:
parser.error("Must specify either --risk-pct or --win-rate mode")
params = SizingParameters(
account_size=args.account_size,
entry_price=args.entry,
stop_price=args.stop,
risk_pct=args.risk_pct,
atr=args.atr,
atr_multiplier=args.atr_multiplier,
win_rate=args.win_rate,
avg_win=args.avg_win,
avg_loss=args.avg_loss,
max_position_pct=args.max_position_pct,
max_sector_pct=args.max_sector_pct,
sector=args.sector,
current_sector_exposure=args.current_sector_exposure,
)
try:
result = calculate_position(params)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Output
os.makedirs(args.output_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d_%H%M%S")
json_path = os.path.join(args.output_dir, f"position_sizer_{timestamp}.json")
with open(json_path, "w") as f:
json.dump(result, f, indent=2)
print(f"JSON report: {json_path}")
md_report = generate_markdown_report(result)
md_path = os.path.join(args.output_dir, f"position_sizer_{timestamp}.md")
with open(md_path, "w") as f:
f.write(md_report)
print(f"Markdown report: {md_path}")
# Also print summary to stdout
if result["mode"] == "shares":
print(
"\nFinal: {} shares @ ${}".format(
result["final_recommended_shares"], params.entry_price
)
)
print("Position: ${:,.2f}".format(result["final_position_value"]))
if result.get("final_risk_dollars") is not None:
print(
"Risk: ${:,.2f} ({}%)".format(
result["final_risk_dollars"], result["final_risk_pct"]
)
)
if result.get("risk_note"):
print("Note: {}".format(result["risk_note"]))
else:
print("\nRecommended risk budget: ${:,.2f}".format(result["recommended_risk_budget"]))
print("({}% of account)".format(result["recommended_risk_budget_pct"]))
if __name__ == "__main__":
main()
```
---
## Skill Companion Files
> Additional files collected from the skill directory layout.
### scripts/tests/conftest.py
```python
"""Shared fixtures for Position Sizer tests"""
import os
import sys
# Add scripts directory to path so modules can be imported
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Add tests directory to path so helpers can be imported
sys.path.insert(0, os.path.dirname(__file__))
```
### scripts/tests/test_position_sizer.py
```python
"""Tests for position_sizer.py — Fixed Fractional, ATR-based, Kelly Criterion sizing."""
import json
import subprocess
import sys
from pathlib import Path
import pytest
from position_sizer import (
SizingParameters,
calculate_atr_based,
calculate_fixed_fractional,
calculate_kelly,
calculate_position,
generate_markdown_report,
validate_parameters,
)
# ─── Test: Basic Fixed Fractional ────────────────────────────────────────────
class TestFixedFractional:
def test_fixed_fractional_basic(self):
"""entry=155, stop=148.50, account=100k, risk=1% -> 153 shares."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
)
result = calculate_fixed_fractional(params)
assert result["risk_per_share"] == 6.50
assert result["dollar_risk"] == 1000.0
assert result["shares"] == 153 # int(1000 / 6.50) = 153
def test_fixed_fractional_penny_stock(self):
"""entry=0.85, stop=0.70, account=50k, risk=0.5% -> 1666 shares."""
params = SizingParameters(
account_size=50_000,
entry_price=0.85,
stop_price=0.70,
risk_pct=0.5,
)
result = calculate_fixed_fractional(params)
assert result["risk_per_share"] == 0.15
assert result["shares"] == 1666 # int(250 / 0.15) = 1666
def test_fixed_fractional_large_account(self):
"""entry=500, stop=475, account=1M, risk=2% -> 800 shares."""
params = SizingParameters(
account_size=1_000_000,
entry_price=500.0,
stop_price=475.0,
risk_pct=2.0,
)
result = calculate_fixed_fractional(params)
assert result["shares"] == 800 # int(20000 / 25) = 800
# ─── Test: ATR-based Sizing ──────────────────────────────────────────────────
class TestATRBased:
def test_atr_based_sizing(self):
"""entry=155, atr=3.20, mult=2.0, account=100k, risk=1%."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
risk_pct=1.0,
atr=3.20,
atr_multiplier=2.0,
)
result = calculate_atr_based(params)
assert result["stop_price"] == 148.60
assert result["risk_per_share"] == 6.40
assert result["shares"] == 156 # int(1000 / 6.40) = 156
def test_atr_multiplier_variations(self):
"""1.5x, 2x, 3x multipliers produce different share counts."""
base = dict(account_size=100_000, entry_price=155.0, risk_pct=1.0, atr=3.20)
r15 = calculate_atr_based(SizingParameters(**base, atr_multiplier=1.5))
r20 = calculate_atr_based(SizingParameters(**base, atr_multiplier=2.0))
r30 = calculate_atr_based(SizingParameters(**base, atr_multiplier=3.0))
# Wider stop -> fewer shares
assert r15["shares"] > r20["shares"] > r30["shares"]
def test_atr_stop_price_calculation(self):
"""stop_price = entry - (atr * atr_multiplier)."""
params = SizingParameters(
account_size=100_000,
entry_price=200.0,
risk_pct=1.0,
atr=5.0,
atr_multiplier=2.5,
)
result = calculate_atr_based(params)
assert result["stop_price"] == 187.50 # 200 - (5 * 2.5)
# ─── Test: Kelly Criterion ───────────────────────────────────────────────────
class TestKelly:
def test_kelly_criterion(self):
"""win_rate=0.55, avg_win=2.5, avg_loss=1.0 -> ~10% kelly."""
params = SizingParameters(
account_size=100_000,
win_rate=0.55,
avg_win=2.5,
avg_loss=1.0,
)
result = calculate_kelly(params)
# K = 0.55 - (0.45 / 2.5) = 0.55 - 0.18 = 0.37 -> 37%?
# Actually: K = W - (1-W)/R = 0.55 - 0.45/2.5 = 0.55 - 0.18 = 0.37
# Wait, the spec says ~10%. Let me re-check the formula.
# The spec says: win_rate=0.55, avg_win=2.5, avg_loss=1.0 -> kelly_pct ~ 10%
# Maybe R = avg_win / avg_loss = 2.5 / 1.0 = 2.5
# K = W - (1-W)/R = 0.55 - 0.45/2.5 = 0.55 - 0.18 = 0.37 = 37%
# But spec says ~10%. Let me check if they mean something different.
# Actually with the given formula the result is 37%. The spec approximation
# may be off. Let's test the actual math: 0.55 - 0.45/2.5 = 0.37 = 37%
assert result["kelly_pct"] == 37.0
assert result["half_kelly_pct"] == 18.5
def test_half_kelly(self):
"""half_kelly = kelly_pct / 2."""
params = SizingParameters(
account_size=100_000,
win_rate=0.60,
avg_win=2.0,
avg_loss=1.0,
)
result = calculate_kelly(params)
assert result["half_kelly_pct"] == result["kelly_pct"] / 2
def test_kelly_negative_expectancy(self):
"""win_rate=0.30, avg_win=1.0, avg_loss=1.5 -> kelly=0, recommended=0."""
params = SizingParameters(
account_size=100_000,
win_rate=0.30,
avg_win=1.0,
avg_loss=1.5,
)
result = calculate_kelly(params)
# K = 0.30 - 0.70 / (1.0/1.5) = 0.30 - 0.70/0.6667 = 0.30 - 1.05 = -0.75
# Floored to 0
assert result["kelly_pct"] == 0.0
assert result["half_kelly_pct"] == 0.0
def test_kelly_budget_mode_no_entry(self):
"""Kelly without --entry -> budget mode, no shares in output."""
params = SizingParameters(
account_size=100_000,
win_rate=0.55,
avg_win=2.5,
avg_loss=1.0,
)
result = calculate_position(params)
assert result["mode"] == "budget"
assert "final_recommended_shares" not in result
assert "recommended_risk_budget" in result
def test_kelly_shares_mode_with_entry(self):
"""Kelly with --entry and --stop -> shares mode."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=148.50,
win_rate=0.55,
avg_win=2.5,
avg_loss=1.0,
)
result = calculate_position(params)
assert result["mode"] == "shares"
assert "final_recommended_shares" in result
def test_kelly_shares_no_stop_risk_note(self):
"""Kelly with --entry but no --stop -> risk_dollars=None, risk_note present."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
win_rate=0.55,
avg_win=2.5,
avg_loss=1.0,
)
result = calculate_position(params)
assert result["mode"] == "shares"
assert result["final_recommended_shares"] > 0
assert result["final_risk_dollars"] is None
assert result["final_risk_pct"] is None
assert "risk_note" in result
# ─── Test: Constraints & Final Recommendation ────────────────────────────────
class TestConstraints:
def test_final_shares_no_constraints(self):
"""No constraints -> risk-calculated shares unchanged."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
)
result = calculate_position(params)
# 153 shares from fixed fractional, no constraints
assert result["final_recommended_shares"] == 153
def test_final_shares_position_limit(self):
"""max_position_pct=10 -> max 64 shares at entry=155."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
max_position_pct=10.0,
)
result = calculate_position(params)
# Max position: 100000 * 10% / 155 = 64 shares
assert result["final_recommended_shares"] == 64
def test_final_shares_sector_limit(self):
"""max_sector_pct=30, current=22 -> max 51 shares."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
max_sector_pct=30.0,
current_sector_exposure=22.0,
)
result = calculate_position(params)
# Remaining: (30 - 22)% * 100000 / 155 = 8000 / 155 = 51
assert result["final_recommended_shares"] == 51
def test_final_shares_multiple_constraints(self):
"""Multiple constraints -> strictest wins (minimum)."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
max_position_pct=10.0,
max_sector_pct=30.0,
current_sector_exposure=22.0,
)
result = calculate_position(params)
# Position limit: 64, sector limit: 51 -> min = 51
assert result["final_recommended_shares"] == 51
def test_binding_constraint_identification(self):
"""Verify binding_constraint field shows the tightest constraint."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
max_position_pct=10.0,
max_sector_pct=30.0,
current_sector_exposure=22.0,
)
result = calculate_position(params)
assert result["binding_constraint"] == "max_sector_pct"
# ─── Test: Input Validation ──────────────────────────────────────────────────
class TestValidation:
def test_stop_above_entry_error(self):
"""stop > entry -> ValueError."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=160.0,
risk_pct=1.0,
)
with pytest.raises(ValueError, match="stop_price must be below entry_price"):
validate_parameters(params)
def test_zero_risk_error(self):
"""risk_pct=0 -> ValueError."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=150.0,
risk_pct=0.0,
)
with pytest.raises(ValueError, match="risk_pct must be positive"):
validate_parameters(params)
def test_negative_risk_error(self):
"""risk_pct=-1 -> ValueError."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=150.0,
risk_pct=-1.0,
)
with pytest.raises(ValueError, match="risk_pct must be positive"):
validate_parameters(params)
def test_win_rate_over_one_error(self):
"""win_rate=1.5 -> ValueError."""
params = SizingParameters(
account_size=100_000,
win_rate=1.5,
avg_win=2.0,
avg_loss=1.0,
)
with pytest.raises(ValueError, match="win_rate must be between"):
validate_parameters(params)
def test_avg_win_zero_error(self):
"""avg_win=0 -> ValueError (would cause ZeroDivisionError in Kelly)."""
params = SizingParameters(
account_size=100_000,
win_rate=0.55,
avg_win=0.0,
avg_loss=1.0,
)
with pytest.raises(ValueError, match="avg_win must be positive"):
validate_parameters(params)
def test_avg_loss_zero_error(self):
"""avg_loss=0 -> ValueError."""
params = SizingParameters(
account_size=100_000,
win_rate=0.55,
avg_win=2.0,
avg_loss=0.0,
)
with pytest.raises(ValueError, match="avg_loss must be positive"):
validate_parameters(params)
def test_account_size_zero_error(self):
"""account_size=0 -> ValueError."""
params = SizingParameters(
account_size=0,
entry_price=155.0,
stop_price=150.0,
risk_pct=1.0,
)
with pytest.raises(ValueError, match="account_size must be positive"):
validate_parameters(params)
def test_atr_negative_error(self):
"""atr=-1 -> ValueError."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
risk_pct=1.0,
atr=-1.0,
)
with pytest.raises(ValueError, match="atr must be positive"):
validate_parameters(params)
def test_entry_zero_error(self):
"""entry=0 -> ValueError."""
params = SizingParameters(
account_size=100_000,
entry_price=0.0,
stop_price=-1.0,
risk_pct=1.0,
)
with pytest.raises(ValueError, match="entry_price must be positive"):
validate_parameters(params)
def test_risk_pct_and_kelly_mutual_exclusive(self):
"""Both risk_pct and win_rate via CLI -> SystemExit (argparse error)."""
script = "skills/position-sizer/scripts/position_sizer.py"
result = subprocess.run(
[
sys.executable,
script,
"--account-size",
"100000",
"--entry",
"155",
"--stop",
"150",
"--risk-pct",
"1.0",
"--win-rate",
"0.55",
"--avg-win",
"2.5",
"--avg-loss",
"1.0",
],
capture_output=True,
text=True,
cwd=str(Path(__file__).resolve().parents[4]),
)
assert result.returncode != 0
# ─── Test: Output ─────────────────────────────────────────────────────────────
class TestOutput:
def test_report_generation_json(self):
"""Verify JSON has required keys."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
)
result = calculate_position(params)
assert "schema_version" in result
assert "mode" in result
assert "parameters" in result
assert "calculations" in result
assert "final_recommended_shares" in result
# Verify JSON-serializable
json_str = json.dumps(result)
parsed = json.loads(json_str)
assert parsed["schema_version"] == "1.0"
def test_report_generation_markdown(self):
"""Verify markdown report is generated with key sections."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
)
result = calculate_position(params)
md = generate_markdown_report(result)
assert "# Position Sizing Report" in md
assert "## Parameters" in md
assert "## Final Recommendation" in md
assert "153" in md # final shares
def test_cli_arguments(self):
"""Verify argparse works for standard cases."""
script = "skills/position-sizer/scripts/position_sizer.py"
result = subprocess.run(
[
sys.executable,
script,
"--account-size",
"100000",
"--entry",
"155",
"--stop",
"148.50",
"--risk-pct",
"1.0",
"--output-dir",
"/tmp/position_sizer_test",
],
capture_output=True,
text=True,
cwd=str(Path(__file__).resolve().parents[4]),
)
assert result.returncode == 0
assert "153 shares" in result.stdout
def test_cli_missing_required(self):
"""Missing --account-size -> SystemExit."""
script = "skills/position-sizer/scripts/position_sizer.py"
result = subprocess.run(
[
sys.executable,
script,
"--entry",
"155",
"--stop",
"150",
"--risk-pct",
"1.0",
],
capture_output=True,
text=True,
cwd=str(Path(__file__).resolve().parents[4]),
)
assert result.returncode != 0
```