#!/usr/bin/env python3
"""
Deterministic position sizing + guardrail check for a single proposed trade,
used by place_trade_prompt.md before any Questrade order is shown to the user.

Reads screen_criteria.json only -- never positions.json's account block, which
holds a stale placeholder total_value_cad the user has confirmed is arbitrary.
Real equity/cash must come from a live Questrade get_balances call and be
passed in by the caller; this module has no brokerage access of its own.

Guardrails are split hard (no override -- refuse to size the trade at all) vs
soft (the caller may let the user type an explicit override and proceed
anyway). This module only computes and reports; it never decides what to do
with a failing result -- that's place_trade_prompt.md's job.

Run manually:
  python3 position_sizer.py --equity 2500 --score 4 --entry 45.10 --stop 42.80 \
      --target 51.90 --open-positions 2 --cash-available 2100 \
      --open-risk 38.50 --realized-pl -42.10 [--json]
"""
import argparse
import json
import math
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path

BASE = Path(__file__).parent
CRITERIA_FILE = BASE / 'screen_criteria.json'


def load_criteria(path=None):
    path = Path(path) if path else CRITERIA_FILE
    with open(path) as f:
        return json.load(f)


def resolve_trade_management(criteria):
    """Shallow-merge aggressive_overrides.trade_management over trade_management
    when active_mode == 'aggressive'. First code implementation of the merge
    daily_scan_prompt.md only describes in prose."""
    tm = dict(criteria.get('trade_management', {}))
    if criteria.get('active_mode') == 'aggressive':
        tm.update(criteria.get('aggressive_overrides', {}).get('trade_management', {}))
    return tm


def risk_pct_for_score(tm, setup_score):
    """Aggressive mode: risk % keyed by setup_score (3/4/5) via
    max_risk_per_trade_pct_by_score. Conservative mode: flat max_risk_per_trade_pct
    regardless of score. Returns None if the score isn't risk-eligible."""
    by_score = tm.get('max_risk_per_trade_pct_by_score')
    if by_score is not None:
        return by_score.get(str(setup_score))
    if setup_score is not None and setup_score >= 3:
        return tm.get('max_risk_per_trade_pct')
    return None


def _parse_ratio(text):
    """Parse a 'A:B' risk/reward string (e.g. '1:3') into a float B/A."""
    if not text:
        return None
    try:
        a, b = text.split(':')
        return float(b) / float(a)
    except (ValueError, ZeroDivisionError):
        return None


@dataclass
class SizingResult:
    shares: int
    dollar_risk_cad: float
    risk_pct_of_equity: float
    risk_per_share_cad: float
    position_cost_cad: float
    risk_reward_ratio: float = None
    guardrails: dict = field(default_factory=dict)
    passed: bool = True
    hard_fail: bool = False
    reasons_failed: list = field(default_factory=list)

    def to_dict(self):
        return asdict(self)


def size_position(*, account_equity_cad, setup_score, entry_price_cad, stop_price_cad,
                   open_position_count, target_price_cad=None, cash_available_cad=None,
                   open_positions_risk_cad=None, realized_pl_cad=None, criteria=None):
    criteria = criteria if criteria is not None else load_criteria()
    tm = resolve_trade_management(criteria)

    guardrails = {}
    reasons_failed = []
    hard_fail = False

    def check(name, ok, detail, hard):
        guardrails[name] = {'pass': ok, 'detail': detail, 'hard': hard}
        if not ok:
            reasons_failed.append(f'{name}: {detail}')
            if hard:
                nonlocal hard_fail
                hard_fail = True

    # Circuit breaker: added 2026-08-12 from a premortem finding that nothing
    # previously slowed new entries during a losing stretch -- every other
    # guardrail here evaluates one trade in isolation. Hard and unoverridable
    # on purpose: a circuit breaker that can be typed past on a bad day isn't one.
    max_drawdown_halt_pct = tm.get('max_drawdown_halt_pct')
    if realized_pl_cad is not None and account_equity_cad:
        drawdown_pct = realized_pl_cad / account_equity_cad * 100
    else:
        drawdown_pct = None
    check(
        'drawdown_circuit_breaker',
        drawdown_pct is None or max_drawdown_halt_pct is None or drawdown_pct > -max_drawdown_halt_pct,
        'not checked -- realized_pl_cad not supplied' if drawdown_pct is None
        else f'cumulative realized P/L {drawdown_pct:.1f}% of equity vs -{max_drawdown_halt_pct}% halt threshold',
        hard=True,
    )

    max_positions = tm.get('max_simultaneous_positions')
    check(
        'max_simultaneous_positions',
        max_positions is None or open_position_count < max_positions,
        f'{open_position_count} open, max {max_positions}',
        hard=True,
    )

    risk_pct = risk_pct_for_score(tm, setup_score)
    check(
        'setup_score_eligible',
        risk_pct is not None,
        f'setup_score {setup_score} has no configured risk %' if risk_pct is None
        else f'setup_score {setup_score} -> {risk_pct}% risk',
        hard=True,
    )

    risk_per_share = entry_price_cad - stop_price_cad
    check(
        'stop_below_entry',
        risk_per_share > 0,
        f'entry {entry_price_cad} vs stop {stop_price_cad} (long-only v1)',
        hard=True,
    )

    if hard_fail:
        # Can't safely compute a share count without a valid risk % or risk-per-share.
        return SizingResult(
            shares=0, dollar_risk_cad=0.0, risk_pct_of_equity=0.0,
            risk_per_share_cad=max(risk_per_share, 0.0), position_cost_cad=0.0,
            risk_reward_ratio=None, guardrails=guardrails, passed=False,
            hard_fail=True, reasons_failed=reasons_failed,
        )

    dollar_risk_budget = account_equity_cad * risk_pct / 100
    shares = math.floor(dollar_risk_budget / risk_per_share)

    cash_reserve_pct = tm.get('cash_reserve_pct', 0)
    if cash_available_cad is not None:
        reserve_cad = account_equity_cad * cash_reserve_pct / 100
        spendable = max(cash_available_cad - reserve_cad, 0.0)
        cash_capped_shares = math.floor(spendable / entry_price_cad) if entry_price_cad > 0 else 0
        check(
            'cash_reserve',
            cash_capped_shares >= shares,
            f'{shares} shares needs ${shares * entry_price_cad:.2f}, '
            f'only ${spendable:.2f} spendable after {cash_reserve_pct}% reserve '
            f'(cash available ${cash_available_cad:.2f})',
            hard=False,
        )
        shares = min(shares, cash_capped_shares)
    else:
        check('cash_reserve', True, 'not checked -- cash_available_cad not supplied', hard=False)

    # Portfolio risk cap: added 2026-08-12 from a premortem finding that
    # max_risk_per_trade_pct only bounds each trade in isolation -- with
    # max_simultaneous_positions positions each near their own cap, a single
    # correlated move (one sector gapping against several at once) could lose
    # far more than any one trade's budget implies. Soft, same mechanism as
    # cash_reserve -- caps shares down rather than blocking outright; if the
    # budget is already fully committed elsewhere this naturally bottoms out
    # at the existing min_shares hard-fail below.
    max_portfolio_risk_pct = tm.get('max_portfolio_risk_pct')
    if open_positions_risk_cad is not None and max_portfolio_risk_pct is not None:
        portfolio_budget_cad = account_equity_cad * max_portfolio_risk_pct / 100
        remaining_budget_cad = max(0.0, portfolio_budget_cad - open_positions_risk_cad)
        portfolio_capped_shares = math.floor(remaining_budget_cad / risk_per_share)
        check(
            'portfolio_risk_cap',
            portfolio_capped_shares >= shares,
            f'{shares} shares needs ${shares * risk_per_share:.2f} more risk, only '
            f'${remaining_budget_cad:.2f} remains under the {max_portfolio_risk_pct}% portfolio cap '
            f'(${open_positions_risk_cad:.2f} already at risk across open positions)',
            hard=False,
        )
        shares = min(shares, portfolio_capped_shares)
    else:
        check(
            'portfolio_risk_cap', True,
            'not checked -- open_positions_risk_cad not supplied', hard=False,
        )

    check('min_shares', shares >= 1, f'{shares} shares after caps', hard=True)

    rr_ratio = None
    if target_price_cad is not None:
        rr_ratio = (target_price_cad - entry_price_cad) / risk_per_share
        min_rr = _parse_ratio(tm.get('min_risk_reward'))
        # Round before comparing so a ratio that lands exactly on the minimum
        # (e.g. 3.0) doesn't fail on float imprecision (2.9999999999996).
        check(
            'min_risk_reward',
            min_rr is None or round(rr_ratio, 4) >= round(min_rr, 4),
            f'{rr_ratio:.2f}:1 vs minimum {tm.get("min_risk_reward")}',
            hard=False,
        )
    else:
        check('min_risk_reward', True, 'not checked -- target_price_cad not supplied', hard=False)

    shares = max(shares, 0)
    dollar_risk_cad = shares * risk_per_share
    hard_fail = any(g['hard'] and not g['pass'] for g in guardrails.values())
    passed = all(g['pass'] for g in guardrails.values())

    return SizingResult(
        shares=shares,
        dollar_risk_cad=round(dollar_risk_cad, 2),
        risk_pct_of_equity=round(dollar_risk_cad / account_equity_cad * 100, 3) if account_equity_cad else 0.0,
        risk_per_share_cad=round(risk_per_share, 4),
        position_cost_cad=round(shares * entry_price_cad, 2),
        risk_reward_ratio=round(rr_ratio, 2) if rr_ratio is not None else None,
        guardrails=guardrails,
        passed=passed,
        hard_fail=hard_fail,
        reasons_failed=reasons_failed,
    )


def _main():
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    p.add_argument('--equity', type=float, required=True, help='Live account equity/net liq, CAD')
    p.add_argument('--score', type=int, required=True, help='Setup score (3-5)')
    p.add_argument('--entry', type=float, required=True, help='Entry price, CAD')
    p.add_argument('--stop', type=float, required=True, help='Stop price, CAD')
    p.add_argument('--target', type=float, default=None, help='Target price, CAD (optional)')
    p.add_argument('--open-positions', type=int, required=True, help='Live open position count')
    p.add_argument('--cash-available', type=float, default=None, help='Live CAD buying power (optional)')
    p.add_argument('--open-risk', type=float, default=None,
                    help='Sum of dollar_risk_cad already committed across open positions, CAD (optional)')
    p.add_argument('--realized-pl', type=float, default=None,
                    help='Cumulative realized P/L across closed positions, CAD -- retrospective.json summary.realized_pl_cad (optional)')
    p.add_argument('--criteria', default=None, help='Override path to screen_criteria.json')
    p.add_argument('--json', action='store_true', help='Emit JSON instead of a human-readable summary')
    args = p.parse_args()

    criteria = load_criteria(args.criteria)
    result = size_position(
        account_equity_cad=args.equity,
        setup_score=args.score,
        entry_price_cad=args.entry,
        stop_price_cad=args.stop,
        open_position_count=args.open_positions,
        target_price_cad=args.target,
        cash_available_cad=args.cash_available,
        open_positions_risk_cad=args.open_risk,
        realized_pl_cad=args.realized_pl,
        criteria=criteria,
    )

    if args.json:
        print(json.dumps(result.to_dict(), indent=2))
    else:
        print(f'shares:            {result.shares}')
        print(f'dollar risk (CAD): {result.dollar_risk_cad}')
        print(f'risk % of equity:  {result.risk_pct_of_equity}')
        print(f'position cost CAD: {result.position_cost_cad}')
        print(f'risk:reward:       {result.risk_reward_ratio}')
        print(f'passed:            {result.passed}  (hard_fail={result.hard_fail})')
        for name, g in result.guardrails.items():
            mark = 'OK' if g['pass'] else ('HARD FAIL' if g['hard'] else 'WARN')
            print(f'  [{mark:9s}] {name}: {g["detail"]}')

    sys.exit(1 if result.hard_fail else 0)


if __name__ == '__main__':
    _main()
