references/sizing_methodologies.md
# 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)
```
In the CLI, whole-share mode remains the default and floors to an integer.
When the broker supports fractional shares, `--fractional --share-precision N`
floors to the requested decimal precision instead of rounding up.
### 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
- When a small account or high-priced stock would otherwise round the position to zero shares, provided the broker supports fractional trading for that security and order type
### Fractional-Share Mode
Fractional shares can make the risk budget usable for small accounts, high-priced stocks, or very tight risk budgets:
```bash
python3 skills/position-sizer/scripts/position_sizer.py \
--account-size 1000 \
--entry 155 \
--stop 148.50 \
--risk-pct 1.0 \
--fractional \
--share-precision 4 \
--output-dir reports/
```
With the inputs above, the risk budget is $10 and risk per share is $6.50.
Whole-share mode returns 1 share. Fractional mode floors to 1.5384 shares,
keeping risk at or below the $10 budget while using more of the account's
intended risk allocation.
Use fractional mode as a sizing calculation, not as broker permission. Brokers
can impose minimum order sizes, limited fractional support by security or order
type, wider effective spreads for small notional orders, margin restrictions,
and different tax-lot behavior.
### 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
When both a stop price and ATR inputs are provided in risk-percentage mode, sizing and the reported risk (`final_risk_*`) follow the ATR-based method — the method that set the share count — and the explicit stop is ignored for risk reporting. In Kelly shares mode the stop (or entry price) drives shares and risk instead, and ATR inputs are ignored (Kelly budget mode takes no prices and reports no risk figures). Risk dollars and percentage round half-up (ties report the larger risk); other rounded outputs in this skill use Python's default half-even `round()`, and only `final_risk_*` rounds half-up by design.
| 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.
### Small-Account Friction
For small accounts, the calculated risk budget can be smaller than the
practical cost of trading. Before accepting a position size, check:
- Bid/ask spread as a percentage of notional value
- Commission, regulatory, platform, or currency-conversion fees
- Slippage on marketable orders
- Broker minimum order value and fractional-share support
- Margin eligibility, borrow availability for shorts, and settlement rules
If these costs consume a meaningful share of planned risk, reduce frequency,
raise the setup-quality bar, use wider time frames, or skip the trade.
### Intraday Margin and Day-Trading Controls
Do not treat the legacy "$25,000 pattern day trader" rule as the only current
constraint. FINRA Notice 26-10 replaced the old day-trading margin requirements,
including the pattern-day-trader day-count and $25,000 minimum-equity
requirements, with new intraday margin standards effective 2026-06-04. FINRA
allows firms to phase in implementation through 2027-10-20, so the practical
rule a trader experiences can still vary by broker during the transition.
Source: https://www.finra.org/rules-guidance/notices/26-10
### 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
"""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 math
import os
import sys
from dataclasses import dataclass
from datetime import datetime
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal, localcontext
CENT = Decimal("0.01")
@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
fractional_shares: bool = False
share_precision: int = 4
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.stop_price <= 0:
raise ValueError("stop_price must be positive (and below entry_price) for long trades")
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 not math.isfinite(params.atr_multiplier) or params.atr_multiplier <= 0:
raise ValueError("atr_multiplier must be finite and 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")
if params.share_precision < 0 or params.share_precision > 8:
raise ValueError("share_precision must be between 0 and 8")
def to_decimal(value: int | float) -> Decimal:
"""Convert a user-facing numeric input without adding binary float noise."""
return Decimal(str(value))
def floor_share_quantity(quantity: Decimal, params: SizingParameters) -> int | float:
"""Floor shares so the result never exceeds the source budget."""
if not params.fractional_shares:
return int(quantity)
quantum = Decimal(1).scaleb(-params.share_precision)
integer_digits = max(quantity.adjusted() + 1, 1)
with localcontext() as context:
context.prec = integer_digits + params.share_precision + 2
floored = quantity.quantize(quantum, rounding=ROUND_FLOOR)
return float(floored)
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 = floor(dollar_risk / risk_per_share)
"""
risk_per_share = to_decimal(params.entry_price) - to_decimal(params.stop_price)
dollar_risk = to_decimal(params.account_size) * to_decimal(params.risk_pct) / Decimal(100)
shares = floor_share_quantity(dollar_risk / risk_per_share, params)
return {
"method": "fixed_fractional",
"shares": shares,
"risk_per_share": round(float(risk_per_share), 2),
"dollar_risk": round(float(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 = to_decimal(params.atr) * to_decimal(params.atr_multiplier)
stop_price = round(float(to_decimal(params.entry_price) - stop_distance), 2)
risk_per_share = stop_distance
dollar_risk = to_decimal(params.account_size) * to_decimal(params.risk_pct) / Decimal(100)
shares = floor_share_quantity(dollar_risk / risk_per_share, params)
return {
"method": "atr_based",
"shares": shares,
"risk_per_share": round(float(risk_per_share), 2),
"dollar_risk": round(float(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 | float, params: SizingParameters
) -> tuple[int | float, 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: list[int | float] = [shares]
binding: str | None = None
if params.max_position_pct is not None and params.entry_price:
max_by_pos = floor_share_quantity(
to_decimal(params.account_size)
* to_decimal(params.max_position_pct)
/ Decimal(100)
/ to_decimal(params.entry_price),
params,
)
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 = to_decimal(params.max_sector_pct) - to_decimal(
params.current_sector_exposure
)
remaining_dollars = remaining_pct / Decimal(100) * to_decimal(params.account_size)
max_by_sector = max(
0,
floor_share_quantity(remaining_dollars / to_decimal(params.entry_price), params),
)
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: int | float = 0
if is_kelly_mode:
kelly = calculate_kelly(params)
calculations["kelly"] = kelly
# Use half-kelly budget to determine shares
budget = (
to_decimal(params.account_size) * to_decimal(kelly["half_kelly_pct"]) / Decimal(100)
)
if params.stop_price is not None:
risk_per_share = to_decimal(params.entry_price) - to_decimal(params.stop_price)
risk_shares = floor_share_quantity(budget / risk_per_share, params)
result["parameters"]["stop_price"] = params.stop_price
else:
risk_shares = floor_share_quantity(budget / to_decimal(params.entry_price), params)
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
if params.fractional_shares:
result["parameters"]["fractional_shares"] = True
result["parameters"]["share_precision"] = params.share_precision
# 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)
# Report risk using the same method that determined the share count.
# Exact Decimal math; the monetized outputs round half-up so a tie never
# understates risk (the float->Decimal switch also removes binary noise
# from near-tie products such as 1.15 * 1.5). Deliberately applies to the
# final_risk_* outputs only; other rounded outputs keep half-even round().
risk_per_share: Decimal | None = None
if calculations["atr_based"] is not None:
risk_per_share = to_decimal(params.atr) * to_decimal(params.atr_multiplier)
elif params.stop_price is not None:
risk_per_share = to_decimal(params.entry_price) - to_decimal(params.stop_price)
if risk_per_share is not None:
risk_total = to_decimal(final_shares) * risk_per_share
result["final_risk_dollars"] = float(risk_total.quantize(CENT, rounding=ROUND_HALF_UP))
result["final_risk_pct"] = float(
(risk_total / to_decimal(params.account_size) * Decimal(100)).quantize(
CENT, rounding=ROUND_HALF_UP
)
)
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="Finite, positive 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(
"--fractional",
action="store_true",
help="Allow fractional share output, floored to --share-precision",
)
parser.add_argument(
"--share-precision",
type=int,
default=4,
help="Decimal places for --fractional share quantities, 0-8 (default: 4)",
)
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,
fractional_shares=args.fractional,
share_precision=args.share_precision,
)
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", encoding="utf-8") 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", encoding="utf-8") 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()
scripts/tests/test_position_sizer.py
"""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,
main,
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 -> 37% kelly, 18.5% half-kelly."""
params = SizingParameters(
account_size=100_000,
win_rate=0.55,
avg_win=2.5,
avg_loss=1.0,
)
result = calculate_kelly(params)
# K = W - (1-W)/R = 0.55 - 0.45/2.5 = 0.55 - 0.18 = 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: Fractional Shares ─────────────────────────────────────────────────
class TestFractionalShares:
def test_fractional_exact_precision_boundary_is_preserved(self):
"""Binary float noise must not floor an exact decimal boundary twice."""
params = SizingParameters(
account_size=1_150,
entry_price=100.0,
stop_price=90.0,
risk_pct=1.0,
fractional_shares=True,
share_precision=2,
)
result = calculate_position(params)
assert result["calculations"]["fixed_fractional"]["shares"] == 1.15
assert result["final_recommended_shares"] == 1.15
assert result["final_risk_dollars"] == 11.5
def test_fractional_decimal_inputs_are_used_before_subtraction(self):
"""Entry-stop subtraction must not pollute an exact share boundary."""
params = SizingParameters(
account_size=18.81,
entry_price=10.0,
stop_price=9.01,
risk_pct=1.0,
fractional_shares=True,
share_precision=2,
)
result = calculate_position(params)
assert result["calculations"]["fixed_fractional"]["shares"] == 0.19
assert result["final_recommended_shares"] == 0.19
assert result["final_risk_dollars"] == 0.19
def test_fractional_fixed_fractional_floors_to_precision(self):
"""--fractional supports small-account sizing without exceeding risk."""
params = SizingParameters(
account_size=1_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
fractional_shares=True,
share_precision=4,
)
result = calculate_position(params)
assert result["calculations"]["fixed_fractional"]["shares"] == 1.5384
assert result["final_recommended_shares"] == 1.5384
assert result["final_risk_dollars"] == 10.0
assert result["final_risk_pct"] == 1.0
assert result["parameters"]["fractional_shares"] is True
assert result["parameters"]["share_precision"] == 4
def test_fractional_atr_based_floors_to_precision(self):
"""ATR mode uses the same fractional flooring logic."""
params = SizingParameters(
account_size=1_000,
entry_price=100.0,
risk_pct=1.0,
atr=3.0,
atr_multiplier=2.0,
fractional_shares=True,
share_precision=4,
)
result = calculate_position(params)
assert result["calculations"]["atr_based"]["shares"] == 1.6666
assert result["final_recommended_shares"] == 1.6666
assert result["final_risk_dollars"] == 10.0
def test_fractional_kelly_shares_with_stop(self):
"""Kelly shares mode supports fractional output when a stop is provided."""
params = SizingParameters(
account_size=100,
entry_price=155.0,
stop_price=148.50,
win_rate=0.55,
avg_win=2.5,
avg_loss=1.0,
fractional_shares=True,
share_precision=4,
)
result = calculate_position(params)
assert result["calculations"]["kelly"]["half_kelly_pct"] == 18.5
assert result["final_recommended_shares"] == 2.8461
assert result["final_risk_dollars"] == 18.5
def test_fractional_max_position_constraint(self):
"""Max-position constraints floor fractional shares before binding."""
params = SizingParameters(
account_size=1_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
max_position_pct=10.0,
fractional_shares=True,
share_precision=4,
)
result = calculate_position(params)
assert result["final_recommended_shares"] == 0.6451
assert result["final_position_value"] == 99.99
assert result["binding_constraint"] == "max_position_pct"
assert result["final_position_value"] <= 100.0
def test_fractional_max_sector_constraint(self):
"""Max-sector constraints floor fractional shares before binding."""
params = SizingParameters(
account_size=1_000,
entry_price=155.0,
stop_price=148.50,
risk_pct=1.0,
max_sector_pct=30.0,
current_sector_exposure=22.0,
fractional_shares=True,
share_precision=4,
)
result = calculate_position(params)
assert result["final_recommended_shares"] == 0.5161
assert result["final_position_value"] == 80.0
assert result["binding_constraint"] == "max_sector_pct"
assert result["final_position_value"] <= 80.0
# ─── Test: Risk Reporting ────────────────────────────────────────────────────
class TestRiskReporting:
@pytest.mark.parametrize(
("max_position_pct", "shares", "risk"),
[(None, 50, 100.0), (10.0, 10, 20.0)],
)
def test_final_risk_uses_selected_atr_stop(self, max_position_pct, shares, risk):
params = SizingParameters(
account_size=10_000,
entry_price=100.0,
stop_price=90.0,
risk_pct=1.0,
atr=1.0,
max_position_pct=max_position_pct,
)
result = calculate_position(params)
assert result["parameters"]["stop_price"] == 98.0
assert result["final_recommended_shares"] == shares
assert result["final_risk_dollars"] == risk
assert result["final_risk_pct"] == risk / 100
@pytest.mark.parametrize("stop_price", [None, 90.0])
def test_kelly_risk_uses_selected_stop(self, stop_price):
params = SizingParameters(
account_size=10_000,
entry_price=100.0,
stop_price=stop_price,
win_rate=0.55,
avg_win=2.5,
avg_loss=1.0,
atr=1.0,
)
result = calculate_position(params)
assert result["calculations"]["atr_based"] is None
if stop_price is None:
assert result["final_risk_dollars"] is None
assert result["final_risk_pct"] is None
assert "risk_note" in result
else:
assert result["final_risk_dollars"] == 1_850.0
assert result["final_risk_pct"] == 18.5
def test_final_risk_decimal_half_up_atr(self):
"""Fractional ATR: reported risk uses exact Decimal math, half-up.
atr=1.15, mult=1.5 -> 1.725 exact; float noise gives 1.7249999...
1 share (dollar risk 2.00 / 1.725 -> floor 1).
Current float code reports 1.72; Decimal half-up reports 1.73.
"""
params = SizingParameters(
account_size=10_000,
entry_price=100.0,
stop_price=90.0,
risk_pct=0.02,
atr=1.15,
atr_multiplier=1.5,
)
result = calculate_position(params)
assert result["final_recommended_shares"] == 1
assert result["final_risk_dollars"] == 1.73
assert result["final_risk_pct"] == 0.02
def test_final_risk_decimal_half_up_atr_second(self):
"""Fractional ATR again: 1.03 * 1.5 = 1.545 exact; 3 shares -> 4.635.
Current float code reports 4.63; Decimal half-up reports 4.64.
"""
params = SizingParameters(
account_size=10_000,
entry_price=100.0,
stop_price=90.0,
risk_pct=0.05,
atr=1.03,
atr_multiplier=1.5,
)
result = calculate_position(params)
assert result["final_recommended_shares"] == 3
assert result["final_risk_dollars"] == 4.64
assert result["final_risk_pct"] == 0.05
def test_kelly_risk_stop_branch_uses_same_rule(self):
"""Lock: Kelly shares mode with entry+stop reports entry-stop risk."""
params = SizingParameters(
account_size=10_000,
entry_price=100.0,
stop_price=90.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"] == 185
assert result["final_risk_dollars"] == 1_850.0
assert result["final_risk_pct"] == 18.5
assert result["final_risk_dollars"] == round(result["final_recommended_shares"] * 10.0, 2)
assert result["final_risk_pct"] == round(
result["final_risk_dollars"] / params.account_size * 100, 2
)
# ─── Test: Input Validation ──────────────────────────────────────────────────
class TestValidation:
@pytest.mark.parametrize(
"multiplier", [0.0, -0.0, -2.0, float("nan"), float("inf"), -float("inf")]
)
@pytest.mark.parametrize("entry_point", [validate_parameters, calculate_position])
def test_invalid_atr_multiplier(self, multiplier, entry_point):
params = SizingParameters(
account_size=100_000,
entry_price=100.0,
risk_pct=1.0,
atr=1.0,
atr_multiplier=multiplier,
max_position_pct=10.0,
)
with pytest.raises(ValueError, match="atr_multiplier must be finite and positive"):
entry_point(params)
@pytest.mark.parametrize("multiplier", ["0", "-0.0", "-2.0", "nan", "inf", "-inf"])
def test_cli_invalid_atr_multiplier_creates_no_reports(self, multiplier, tmp_path):
script = Path(__file__).resolve().parents[1] / "position_sizer.py"
output_dir = tmp_path / "reports"
result = subprocess.run(
[
sys.executable,
str(script),
"--account-size",
"100000",
"--entry",
"100",
"--risk-pct",
"1",
"--atr",
"1",
f"--atr-multiplier={multiplier}",
"--max-position-pct",
"10",
"--output-dir",
str(output_dir),
],
capture_output=True,
text=True,
cwd=tmp_path,
)
assert result.returncode == 1
assert result.stderr.strip() == "Error: atr_multiplier must be finite and positive"
assert "Traceback" not in result.stderr
assert result.stdout == ""
assert not output_dir.exists()
@pytest.mark.parametrize(
"mode_parameters",
[
{"entry_price": 100.0, "stop_price": 95.0, "risk_pct": 1.0},
{"win_rate": 0.55, "avg_win": 2.5, "avg_loss": 1.0},
],
ids=["fixed_fractional", "kelly_budget"],
)
def test_unused_invalid_atr_multiplier_is_still_rejected(self, mode_parameters):
params = SizingParameters(account_size=100_000, atr_multiplier=0, **mode_parameters)
with pytest.raises(ValueError, match="atr_multiplier must be finite and positive"):
calculate_position(params)
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)
@pytest.mark.parametrize("stop_price", [0.0, -1.0])
def test_stop_must_be_positive(self, stop_price):
"""stop <= 0 -> ValueError (long stop at or below zero is meaningless)."""
params = SizingParameters(
account_size=10_000,
entry_price=100.0,
stop_price=stop_price,
risk_pct=1.0,
)
with pytest.raises(ValueError, match="stop_price must be positive"):
validate_parameters(params)
@pytest.mark.parametrize("stop_price", [0.0, -1.0])
def test_stop_must_be_positive_kelly_budget(self, stop_price):
"""stop <= 0 is rejected in Kelly budget mode as well."""
params = SizingParameters(
account_size=10_000,
stop_price=stop_price,
win_rate=0.55,
avg_win=2.5,
avg_loss=1.0,
)
with pytest.raises(ValueError, match="stop_price must be positive"):
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_share_precision_below_zero_error(self):
"""share_precision below zero -> ValueError."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=150.0,
risk_pct=1.0,
fractional_shares=True,
share_precision=-1,
)
with pytest.raises(ValueError, match="share_precision must be between 0 and 8"):
validate_parameters(params)
def test_share_precision_above_eight_error(self):
"""share_precision above eight -> ValueError."""
params = SizingParameters(
account_size=100_000,
entry_price=155.0,
stop_price=150.0,
risk_pct=1.0,
fractional_shares=True,
share_precision=9,
)
with pytest.raises(ValueError, match="share_precision must be between 0 and 8"):
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_main_writes_risk_pct_reports_in_process(self, monkeypatch, tmp_path, capsys):
"""The CLI entry point writes both reports and prints its share summary."""
monkeypatch.setattr(
sys,
"argv",
[
"position_sizer.py",
"--account-size",
"100000",
"--entry",
"155",
"--stop",
"148.50",
"--risk-pct",
"1.0",
"--output-dir",
str(tmp_path),
],
)
main()
stdout = capsys.readouterr().out
assert "Final: 153 shares" in stdout
assert "JSON report:" in stdout
assert "Markdown report:" in stdout
json_reports = list(tmp_path.glob("position_sizer_*.json"))
markdown_reports = list(tmp_path.glob("position_sizer_*.md"))
assert len(json_reports) == 1
assert len(markdown_reports) == 1
payload = json.loads(json_reports[0].read_text(encoding="utf-8"))
assert payload["mode"] == "shares"
assert payload["final_recommended_shares"] == 153
markdown = markdown_reports[0].read_text(encoding="utf-8")
assert "# Position Sizing Report" in markdown
assert "**Shares:** 153" in markdown
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_fractional_arguments(self):
"""Verify CLI supports fractional share output."""
script = "skills/position-sizer/scripts/position_sizer.py"
result = subprocess.run(
[
sys.executable,
script,
"--account-size",
"1000",
"--entry",
"155",
"--stop",
"148.50",
"--risk-pct",
"1.0",
"--fractional",
"--share-precision",
"4",
"--output-dir",
"/tmp/position_sizer_test",
],
capture_output=True,
text=True,
cwd=str(Path(__file__).resolve().parents[4]),
)
assert result.returncode == 0
assert "1.5384 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