CoolFace
Apppublic

thinkingEverytime/QuantOracle

sourceHugging Faceupdated 6mo agoView on Hugging Face
1likes
portfolio.py119 linesDownload Raw Back to quant
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