Risk management framework for the 2K→200K ASX portfolio lifecycle. Designed as a module that composes with the existing fee_gate.py — it is NOT a replacement but an additive layer on top of the fee-aware signal gating already implemented.
Core Principles (from Phase 1)
No stop-losses: BH through drawdowns is the strategy. Circuit breakers are portfolio-level cooling periods, not individual stock sells.
Fee-first risk: The dominant risk for <$10K portfolios is fee drag from over-trading. Position sizing tiers enforce minimum trade sizes.
DRIP as natural insurance: Dividend reinvestment adds ~485pp edge — it acts as a compounding buffer during drawdowns.
Module 1: Position Sizing Engine (position_sizer.py)
Capital-Tier System (aligned with fee_gate.py)
Tier
Capital
Sizing Method
Max Positions
Min Trade Value
T1
2K–10K
Fixed lot (integer shares)
8–15 tickers
AUD $40+ per position
T2
10K–50K
Fractional risk-based (1-2%)
15–30 tickers
AUD $100+ per position
T3
$50K+
Kelly-fraction (fractional Kelly)
20–40 tickers
AUD $200+ per position
Position Sizing Algorithm
class PositionSizer: """Calculate position size for a given signal and portfolio state.""" def __init__(self, capital: float, tier_config: dict): self.capital = capital self.tier = self._classify_tier(capital) def size_position(self, ticker: str, signal_strength: float, volatility_annualized: float) -> int | float: """Return share count (int for T1, float for T2/T3).""" if self.tier == "T1": return self._fixed_lot(ticker, signal_strength) elif self.tier == "T2": return self._fractional_risk(signal_strength, volatility_annualized) else: # T3 return self._kelly_fractional(signal_strength, volatility_annualized) def _volatility_adjusted_size(self, base_size: float, vol: float) -> float: """Reduce position size inversely proportional to volatility. ASX sector volatility bands (from Phase 4 research): - Mining/resources: 30-40% annualised - Financials: 20-30% annualised - Consumer staples: 10-20% annualised Inverse vol weighting: size *= reference_vol / actual_vol """ REFERENCE_VOL = 0.25 # 25% is "normal" return base_size * (REFERENCE_VOL / vol) if vol > 0 else base_size
Minimum order value: AUD 40perposition(coversIBKRround−trip13.40 + 5% buffer for spread). For a 2Kportfoliowith19tickers,thismeans106/position target → many high-priced ASX stocks (RIO.AX at $178+) are excluded from Tier 1 positions.
Practical T1 universe: Limited to tickers priced < AUD 45pershare→BHP(58, borderline), TLS (5.38),FMG(21.20), CBA (~95,excluded),NAB(30, included). Universe contracts to ~8-10 tickers at $2K capital.
Expansion trigger: When portfolio reaches $10K (5x starting capital), automatically upgrade to Tier 2 fractional sizing.
Tier 3 Kelly Fraction
def kelly_fraction(self, win_rate: float, avg_win: float, avg_loss: float) -> float: """Kelly criterion for BH + DRIP strategy.""" # For buy-and-hold with quarterly rebalancing: # Win rate ≈ 0.65 (ASX compounders in uptrend 60-70% of time) # Avg win ≈ avg_loss × 1.5 (asymmetric upside from compounding) b = avg_win / avg_loss if avg_loss > 0 else 1.5 p = win_rate return (b * p - (1 - p)) / bdef fractional_kelly(self, kelly: float, fraction: float = 0.25) -> float: """Use 25% of full Kelly to reduce variance (half-Kelly = 50% volatility reduction).""" return kelly * fraction
class DrawdownGuard: """Portfolio-level drawdown protection. Does NOT trigger individual sells.""" def __init__(self, peak_tracker: PeakTracker): self.peak = peak_tracker.current_peak def check(self, portfolio_value: float) -> str: """Return action string based on current drawdown.""" dd_pct = (self.peak - portfolio_value) / self.peak if dd_pct > 0.40: return "HALT_ALL" elif dd_pct > 0.25: return "REVIEW_CHECKPOINT" elif dd_pct > 0.15: return "COOLING_PERIOD_90D" else: return "NORMAL"
DRIP Cushioning Effect
During drawdowns, dividend reinvestment naturally reduces share count decline. The +485pp edge from DRIP over 11 years means ~12%/year additional compounding that partially offsets price declines. This should be factored into drawdown severity assessment: