thinkingEverytime/QuantOracle
1
1"""Long/short portfolio construction (simple constraints, no optimization bloat)."""2 3from __future__ import annotations4 5from dataclasses import dataclass6from typing import Dict7 8import numpy as np9 10 11@dataclass(frozen=True)12class Constraints:13 long_n: int = 1014 short_n: int = 1015 gross: float = 1.0 # sum(abs(w))16 net: float = 0.0 # sum(w)17 max_abs_weight: float = 0.1018 19 20def _clip_and_renorm(w: Dict[str, float], gross: float, max_abs: float) -> Dict[str, float]:21 if not w or gross <= 0 or max_abs <= 0:22 return {}23 24 items = [(k, float(v)) for k, v in w.items() if v and np.isfinite(v)]25 if not items:26 return {}27 28 keys, vals = zip(*items)29 vals = np.array(vals, dtype=float)30 signs = np.sign(vals)31 abs0 = np.abs(vals)32 s0 = float(abs0.sum())33 if s0 <= 0:34 return {}35 36 cap = float(max_abs)37 target_gross = float(min(gross, cap * len(keys))) # infeasible gross -> respect caps38 39 # Allocate absolute weights to hit target_gross while respecting cap, preserving proportions.40 p = abs0 / s041 alloc = np.zeros_like(abs0)42 free = np.ones_like(abs0, dtype=bool)43 tol = 1e-1244 for _ in range(len(keys)):45 if not free.any():46 break47 remaining = target_gross - float(alloc[~free].sum())48 if remaining <= tol:49 break50 p_free = p[free]51 p_sum = float(p_free.sum())52 if p_sum <= tol:53 alloc[free] = min(cap, remaining / float(free.sum()))54 break55 cand = remaining * (p_free / p_sum)56 over = cand > cap + tol57 alloc[free] = np.minimum(cand, cap)58 if not over.any():59 break60 free_idx = np.where(free)[0]61 free[free_idx[over]] = False62 63 out = {k: float(s * a) for k, s, a in zip(keys, signs, alloc) if a > 0}64 return out65 66 67def build_long_short(68 preds: Dict[str, float],69 risks: Dict[str, float],70 c: Constraints,71) -> Dict[str, float]:72 """73 preds: expected return (higher = better)74 risks: volatility proxy (higher = riskier). Must be >0.75 """76 items = [(s, float(mu)) for s, mu in preds.items() if s in risks and risks[s] and np.isfinite(risks[s])]77 if not items:78 return {}79 80 # Risk-adjusted score; higher is better. (No bloat: keep one score definition.)81 scored = [(s, mu / (float(risks[s]) ** 2 + 1e-12)) for s, mu in items]82 scored.sort(key=lambda x: x[1], reverse=True)83 84 def alloc_side(picks: list[tuple[str, float]], side_gross: float, sign: float, invert: bool) -> Dict[str, float]:85 if not picks or side_gross <= 0:86 return {}87 v = np.array([x for _, x in picks], dtype=float)88 v = (float(v.max()) - v) if invert else (v - float(v.min()))89 v = np.maximum(v, 0.0) + 1e-1290 w0 = {s: float(sign) * float(x) for (s, _), x in zip(picks, v)}91 return _clip_and_renorm(w0, gross=side_gross, max_abs=c.max_abs_weight)92 93 long_n = max(0, int(c.long_n))94 short_n = max(0, int(c.short_n))95 96 longs = scored[:long_n] if long_n else []97 long_keys = {s for s, _ in longs}98 shorts = [(s, v) for s, v in scored[-short_n:]] if short_n else []99 shorts = [(s, v) for s, v in shorts if s not in long_keys] # avoid overlap when universe is tiny100 101 both_sides = bool(longs) and bool(shorts)102 if both_sides:103 long_gross = 0.5 * (float(c.gross) + float(c.net))104 short_gross = 0.5 * (float(c.gross) - float(c.net))105 if long_gross < 0 or short_gross < 0:106 # Infeasible gross/net; respect gross and keep the dominant side.107 long_gross = float(c.gross) if c.net >= 0 else 0.0108 short_gross = 0.0 if c.net >= 0 else float(c.gross)109 else:110 long_gross = float(c.gross) if longs else 0.0111 short_gross = float(c.gross) if shorts and not longs else 0.0112 113 w: Dict[str, float] = {}114 w.update(alloc_side(longs, long_gross, sign=+1.0, invert=False))115 for k, v in alloc_side(shorts, short_gross, sign=-1.0, invert=True).items():116 w[k] = w.get(k, 0.0) + float(v)117 118 return {k: float(v) for k, v in w.items() if v}119 