Skip to main content

Portfolio allocation

The portfolio manager decides how much weight to give each symbol, using a constraint solver (Google OR-Tools) rather than ad-hoc rules. It maximizes total expected score subject to hard constraints.

make allocate
# or
uv run python main.py allocate --scanner volume --symbols NVDA,META,TSLA,AMD \
--capital 100000 --max-positions 5 --max-weight 0.25

Requires the optional extra:

make install-portfolio # or: uv sync --extra portfolio

What it solves

Each scanned symbol becomes a candidate with a score (here, its trailing return — a transparent, swappable factor) and a price. The solver chooses weights to maximize the score-weighted allocation subject to:

  • invest at most 100% of capital,
  • hold at most --max-positions names,
  • cap any single name at --max-weight.

Example output:

SYMBOL WEIGHT DOLLARS SHARES
NVDA 25.0% 25,000.00 190
META 25.0% 25,000.00 51
TSLA 20.0% 20,000.00 80

Choosing the factor

The CLI scores by trailing return, but the allocator accepts any score. Swap in momentum, inverse volatility, a model's expected return, or signal strength — see Portfolio (engineering) for the model and how to change the scoring factor.

Mean-variance construction (--objective utility)

The default allocate is a scalar-score sizer. --objective utility instead builds the risk-adjusted, cost-aware portfolio from a strategy's alpha and the covariance Σ — maximizing αᵀw − λ·wᵀΣw − cost(Δw) at a target tracking error. It is a read-only research proposal (it places no orders):

python main.py allocate --objective utility \
--strategy volume_spike --symbols NVDA,AAPL,META,AMD,TSLA,GOOG,MSFT,AMZN \
--as-of 2025-06-01 --target-te 0.04 --max-names 20 --capital 1000000
Portfolio for 'volume_spike' as of 2025-06-01 (target TE 4%, cost-aware)
IR* 0.81 predicted TE 3.9% predicted IR 0.74 transfer coef 0.91 turnover 18.0%
net active return 3.62%/yr (round-trip) = gross 3.85% − round-trip cost 0.23%
this rebalance: turnover cost 0.11%/yr one-way (linear 0.08% + √-impact 0.03%); one-way net 3.74%
capacity ≈ $340,000,000 (where √-impact erases the alpha)

SYMBOL WEIGHT DOLLARS SHARES
NVDA 25.0% 250,000.00 190
AMD 18.0% 180,000.00 142
...

Read it as: IR* is the best information ratio achievable from these alphas and this Σ, before cost; the transfer coefficient is how much of it survives your constraints and cost (tighten --max-names/--max-weight, or trade a costlier book, and watch it fall); predicted IRTC · IR*. net active return is the headline, net of a round-trip cost haircut on the held book; the one-way line below it is the detail — exactly what this rebalance's turnover cost, split into linear (commission + spread) and square-root impact. See Portfolio construction (engineering) for the math.

Cost-aware is the default — with --capital set, the objective carries a name-specific linear turnover cost and a square-root market-impact term, and a no-trade band emerges from each name's own cost (cheap, liquid names trade freely; expensive ones need a clearer signal to move). Pass --gross-objective to drop cost from the objective (it's still reported ex-post) — useful for seeing how much the cost-aware solve actually bought you. --holding-period (years, default 1/12) sets the amortization horizon for the in-objective cost.

--neutralize-factors builds the book from factor-neutral alphas (bare flag = market,volatility,size; momentum kept as a deliberate return tilt) — see Ranking by alpha for the semantics and the honesty warning when exposures are unavailable.

Benchmark-relative construction

--benchmark-holdings equal (or a symbol,weight CSV/JSON file) makes the benchmark a genuine portfolio, not just a beta/vol return series. Tracking error, alpha neutralization, and the transfer coefficient all move into active space (w_a = w − w_B):

python main.py allocate --objective utility --strategy volume_spike \
--symbols NVDA,AAPL,META,AMD,TSLA,GOOG,MSFT,AMZN --as-of 2025-06-01 \
--benchmark-holdings equal --benchmark-premium 0.05

The report adds active beta, residual risk (the ψ² = β_a²σ_B² + ω² split), and a consensus-returns block — the reverse-optimized expected returns for which w_B is itself mean-variance optimal, so you can see what your alphas are really deviations from. Without --benchmark-holdings everything stays cash-relative, byte-for-byte unchanged. See Portfolio construction — benchmark-relative.

Long/short (--book market-neutral)

The long-only box forces every unattractive name to a forced underweight it can't relax below zero. --book market-neutral relaxes the box to [−short-max-weight, max-weight] and the budget to Σw = 0; a gross-leverage cap is then mandatory (an unconstrained long/short book on a noisy Σ is a leverage machine):

python main.py allocate --objective utility --strategy volume_spike \
--symbols NVDA,AAPL,META,AMD,TSLA,GOOG,MSFT,AMZN --as-of 2025-06-01 \
--book market-neutral --gross-leverage 2.0 --short-max-weight 0.25

The report adds the dollar-neutral residual, realized gross leverage vs the cap, and the short book's borrow carry. --longshort-report solves the same alphas/Σ/cost both ways and prices the long-only constraint directly: the measured IR shrinkage, both transfer coefficients, and the long-only book's incidental size tilt. See Portfolio construction — long/short.

Conditional risk (--conditional)

--conditional ewma or --conditional har conditions Σ's volatilities on recent history before the solve, so --target-te is measured against current risk rather than a flat trailing-window average. Default off — see Estimating risk — conditional risk for the evidence gate that decides whether it's worth turning on for your data.

Black–Litterman (--posterior bl)

--posterior bl blends the refined alphas with the reverse-optimized consensus prior, so names outside your alpha's coverage get a real, correlation-propagated posterior instead of being silently excluded:

python main.py allocate --objective utility --strategy volume_spike \
--symbols NVDA,AAPL,META,AMD,TSLA,GOOG,MSFT,AMZN --as-of 2025-06-01 \
--benchmark-holdings equal --posterior bl --posterior-t-eff 60

--posterior-t-eff is required (pass the effective_t a prior info call measured); --tau overrides the pinned τ for sensitivity. Default off until validated out-of-sample. See Portfolio construction — Black–Litterman.

Multi-period trading

--policy aim replaces the myopic "jump to this period's optimum" with a partial-adjustment policy: alphas are discounted for how fast they'll decay before the book finishes trading into them, and the book moves a derived fraction κ of the gap each rebalance instead of all of it:

python main.py allocate --objective utility --strategy volume_spike \
--symbols NVDA,AAPL,META,AMD,TSLA,GOOG,MSFT,AMZN --as-of 2025-06-01 \
--capital 1000000 --policy aim
policy 'aim': κ 0.893 (derived 0.893) trading half-life 0.8 rebalances φ 0.000/rebalance discount 1.00
decay half-life inf bars (upper CI bound inf, fit R² 0.16, used conservatively)

--trade-rate overrides the derived κ directly. Default off — decide whether it's worth turning on with the net-of-cost A/B:

python main.py info --policy-ab --strategy volume_spike \
--symbols NVDA,AAPL,META,AMD,TSLA,GOOG,MSFT,AMZN --start 2024-01-01 --end 2024-12-31

which walks the same alpha book forward under the myopic policy and the aim policy and reports which one actually won net of cost — not a preference. See Multi-period trading (engineering) for the derivation.