"""Capital-allocation simulation/backtest engine. Reusable for both backtest (historical) and forward test (paper, no MT5 send) — the user asked these share one engine, differing only in whether an MT5 order is dispatched. Pure local research; never sends a real order. Allocation rules (confirmed by the user, 2026-08-25): Bucket 1 (50% of capital) : highest "profit-opportunity" score that pays a dividend Bucket 2 (20% of capital) : highest "profit-opportunity" score that does NOT pay a dividend Bucket 3 (30% of capital) : highest dividend yield among names NOT already bought in bucket 1 (ignores score) Per symbol a minimum of 100 shares; rank candidates by combined score descending. If a bucket's first pick can't afford 100 shares, try progressively cheaper eligible names; if none fits, leave the remainder as cash. The engine is honest about data provenance: it runs on *revised vendor history* (non-PIT), so output must be labeled paper/backtest, never validated PIT evidence. """ from __future__ import annotations import json import math from dataclasses import dataclass, field from pathlib import Path from typing import Optional _PRICES_DIR = Path(__file__).resolve().parent.parent / "data" / "prices" MIN_SHARES = 100 class SimulationError(Exception): """Raised for invalid capital / allocation inputs.""" @dataclass class Candidate: symbol: str price: float combined_score: float is_dividend: bool dividend_yield: float @dataclass class Order: symbol: str bucket: int qty: int price: float notional: float @dataclass class AllocationResult: capital: float bucket_allocation: dict = field(default_factory=dict) # {1: amount, 2:..., 3:...} orders: list = field(default_factory=list) invested: float = 0.0 unallocated_cash: float = 0.0 bucket_notional: dict = field(default_factory=dict) # {1: notional, ...} def to_dict(self) -> dict: return { "capital": self.capital, "buckets": self.bucket_allocation, "orders": [o.__dict__ for o in self.orders], "invested": round(self.invested, 2), "unallocated_cash": round(self.unallocated_cash, 2), "bucket_notional": self.bucket_notional, } # --------------------------------------------------------------------------- # B1: price-series loader # --------------------------------------------------------------------------- def load_price_snapshot(snapshot_path: Optional[Path] = None) -> dict: """Load the newest Yahoo price snapshot: {symbol: {bars: [...]}}.""" if snapshot_path is None: snap_dir = _PRICES_DIR / "snapshots" files = sorted(snap_dir.glob("prices-yahoo-chart-*.json")) if not files: raise SimulationError("no Yahoo price snapshot found on disk") snapshot_path = files[-1] data = json.loads(snapshot_path.read_text(encoding="utf-8")) return data.get("series", {}) def latest_prices(series: dict) -> dict[str, float]: """Latest adjusted_close per symbol from the price series.""" out: dict[str, float] = {} for sym, s in series.items(): bars = s.get("bars", []) if bars: out[sym] = float(bars[-1]["adjusted_close"]) return out # --------------------------------------------------------------------------- # B2: capital allocation core # --------------------------------------------------------------------------- def allocate_capital( capital: float, candidates: list[Candidate], bucket_b1: float = 0.50, bucket_b2: float = 0.20, bucket_b3: float = 0.30, ) -> AllocationResult: """Allocate `capital` across the three dividend/profit buckets.""" if capital <= 0: raise SimulationError("capital must be > 0") if not candidates: raise SimulationError("no candidates to allocate") # sort all by combined_score desc (used for bucket 1 & 2 ranking) by_score = sorted(candidates, key=lambda c: -c.combined_score) # bucket 3 ranked by dividend yield desc among dividend payers by_yield = sorted( (c for c in candidates if c.is_dividend and c.dividend_yield > 0), key=lambda c: -c.dividend_yield, ) b1_amount = capital * bucket_b1 b2_amount = capital * bucket_b2 b3_amount = capital * bucket_b3 result = AllocationResult( capital=capital, bucket_allocation={1: b1_amount, 2: b2_amount, 3: b3_amount}, ) cash = [b1_amount, b2_amount, b3_amount] # per-bucket remaining used = set() def _fill(bucket_idx: int, eligible: list[Candidate], require_dividend: bool, sort_by: str = "combined_score"): nonlocal cash, used remaining = cash[bucket_idx] # bucket 3 must rank by dividend_yield (ignoring score); others by score. if sort_by == "dividend_yield": key = lambda c: -c.dividend_yield else: key = lambda c: -c.combined_score for cand in sorted(eligible, key=key): if cand.symbol in used: continue if require_dividend and not cand.is_dividend: continue if cand.price <= 0: continue # max shares affordable within this bucket, floor to 100-share lots max_qty = int(remaining // cand.price) qty = (max_qty // MIN_SHARES) * MIN_SHARES if qty < MIN_SHARES: continue # can't afford minimum; try cheaper name notional = qty * cand.price result.orders.append( Order(cand.symbol, bucket_idx + 1, qty, cand.price, notional) ) remaining -= notional used.add(cand.symbol) result.invested += notional cash[bucket_idx] = remaining result.bucket_notional[bucket_idx + 1] = b_st = ( result.bucket_allocation[bucket_idx + 1] - remaining ) # Bucket 1: dividend-paying, highest score _fill(0, [c for c in by_score if c.is_dividend], require_dividend=True) # Bucket 2: non-dividend, highest score _fill(1, [c for c in by_score if not c.is_dividend], require_dividend=False) # Bucket 3: highest dividend yield (ignoring score), excluding symbols bought _fill(2, by_yield, require_dividend=True, sort_by="dividend_yield") result.unallocated_cash = sum(cash) return result