Risk Framework Specification

Overview

Risk management framework for the 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)

TierCapitalSizing MethodMax PositionsMin Trade Value
T110KFixed lot (integer shares)8–15 tickersAUD $40+ per position
T250KFractional risk-based (1-2%)15–30 tickersAUD $100+ per position
T3$50K+Kelly-fraction (fractional Kelly)20–40 tickersAUD $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

Tier 1 Fixed-Lot Constraints ($2K starting capital)

  • Minimum order value: AUD 13.40 + 5% buffer for spread). For a 106/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 58, borderline), TLS (21.20), CBA (~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)) / b
 
def 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

Module 2: Drawdown Circuit Breakers (drawdown_protection.py)

No Hard Stop-Losses (Phase 1 Rule)

Stop-losses kill returns on ASX compounders (-149pp penalty per Phase 1 research). Instead, use portfolio-level cooling periods:

TriggerActionDuration
>15% peak drawdownPause new entries for one quarter90 calendar days
>25% peak drawdownReview checkpoint — re-evaluate strategy assumptionsManual review required
>40% drawdown (GFC-level)Halt all activity, manual interventionUntil pvs sign-off
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:

  • Effective drawdown = (price_drawdown - annual_DRIP_cushion)
  • For 150-300/year in reinvested dividends

Module 3: Concentration Limits (concentration_guard.py)

Position Limits

LimitThresholdRationale
Max single position5% of portfolioEqual-weight default for 19-ticker universe
Max high-conviction position8%Dividend compounders with strong DRIP edge (FMG, BHP)
Min single position2% of portfolioBelow this = fee drag exceeds position benefit

Sector Limits

LimitThresholdRationale
Max sector weight60%Avoid single-sector collapse (e.g., mining downturn)
Recommended diversification≤40% per sectorBalance across mining, financials, consumer staples
Sector review trigger>50% in any sectorFlag for quarterly rebalance adjustment

Concentration Risk Metrics

class ConcentrationGuard:
    """Monitor portfolio concentration and flag risks."""
    
    @staticmethod
    def herfindahl_index(position_weights: list[float]) -> float:
        """Herfindahl-Hirschman Index (HHI) for portfolio concentration.
        
        0 = perfect diversification, 1 = single-position concentration.
        ASX equal-weight 19-ticker: HHI ≈ 0.053
        Flag at HHI > 0.15
        """
        return sum(w ** 2 for w in position_weights)
    
    @staticmethod
    def check_sector_concentration(
        sector_weights: dict[str, float], max_pct: float = 0.60
    ) -> list[dict]:
        """Return list of sectors exceeding concentration limit."""
        return [
            {"sector": s, "weight": w, "action": "reduce"}
            for s, w in sector_weights.items()
            if w > max_pct
        ]

Module 4: Stress Testing (stress_test.py)

Historical Scenarios

ScenarioASX ImpactRecovery TimeDRIP Effect
COVID (Mar 2020)-25% (ASX 200)~18 months BH+DRIPDRIP cushion: +~4% annual
GFC (Nov 2008)-35% (ASX 200)~36 months BHMining stocks hardest hit
Dotcom (Mar 2000)-20% (ASX 200)~12 months BHLess DRIP data available
Rate shock scenario-15-20% estimated18-24 monthsFinancials hit hardest

Stress Test Execution

class StressTester:
    """Run historical stress scenarios against portfolio."""
    
    SCENARIOS = {
        "covid_2020": {"peak_dd": -0.25, "recovery_mo": 18},
        "gfc_2008": {"peak_dd": -0.35, "recovery_mo": 36},
        "dotcom_2000": {"peak_dd": -0.20, "recovery_mo": 12},
    }
    
    def run_scenario(self, scenario: str, portfolio_value: float) -> dict:
        """Calculate portfolio impact under stress scenario."""
        dd = self.SCENARIOS[scenario]["peak_dd"]
        recovery_mo = self.SCENARIOS[scenario]["recovery_mo"]
        
        worst_case_value = portfolio_value * (1 + dd)
        dr Cushion_annual = portfolio_value * 0.04  # ~4% DRIP cushion
        
        return {
            "scenario": scenario,
            "peak_drawdown_pct": abs(dd) * 100,
            "worst_case_value": round(worst_case_value, 2),
            "estimated_recovery_months": recovery_mo,
            "drip_cushion_annual": dr Cushion_annual,
        }

Monte Carlo Forward Test (Future Enhancement)

For Phase 5, extend with Monte Carlo simulation:

  • Generate 10,000 random paths using ASX historical return distribution (μ≈7%/yr, σ≈18%/yr for compounders)
  • Calculate probability of reaching $200K within time horizon
  • Identify worst-case drawdown percentiles

Integration with Existing Modules

# Composition pattern: risk_framework wraps fee_gate
class RiskGate:
    """Composable risk check that runs AFTER fee gate."""
    
    def __init__(self, capital: float):
        self.sizer = PositionSizer(capital)
        self.drawdown = DrawdownGuard(PeakTracker())
        self.concentration = ConcentrationGuard()
    
    def validate(self, signal: Signal) -> tuple[bool, str]:
        """Full risk validation chain."""
        # 1. Fee gate (already exists)
        if not fee_gate(signal, self.sizer.capital):
            return False, "fee_gate_rejected"
        
        # 2. Position sizing within tier limits
        shares = self.sizer.size_position(
            signal.ticker, signal.strength, signal.volatility
        )
        if shares <= 0:
            return False, "position_too_small_for_tier"
        
        # 3. Drawdown check (only affects new entries, not existing positions)
        action = self.drawdown.check(current_portfolio_value)
        if action == "HALT_ALL":
            return False, "drawdown_halt_active"
        elif action == "COOLING_PERIOD_90D":
            # Allow dividend reinvestment, block new entries
            if signal.direction == "buy" and not signal.is_drivip_reinvest:
                return False, "cooling_period_block_new_entry"
        
        return True, "approved"

Phase 4 Deliverables

#DeliverableStatus
1risk-framework-spec.md — this document✅ Done
2Position sizing engine implementation (position_sizer.py)⬜ Next
3Drawdown guard implementation + tests⬜ After #2
4Concentration guard with HHI metrics⬜ Parallel to #2
5Stress test module with historical scenarios⬜ After #3

References

  • signal-engine-v2-design — Signal engine architecture
  • trade-pattern-v1 — Fee-aware rebalancing tiers (capital-tier system)
  • fee_gate.py — Fee gate implementation (composes with risk framework)
  • Phase 4 research session: sessions/2026-06-10.md — position sizing models, drawdown analysis