Strategy icon

Hyperliquid vault of vaults beta

AI-driven allocation strategy for Hyperliquid vaults

Source code

The source code of the Hyperliquid vault of vaults strategy

"""Hyperliquid vault strategy using trailing CAGR and Sortino for selection.

Eligible vaults are ranked by a weighted composite of 360-day CAGR and 45-day
Sortino scores. A vault must also have a 14-day return above -16%. The strategy
selects up to six vaults and sizes them by inverse variance using a 90-day
volatility estimate. A vault closed to new deposits is not added to the basket,
but an existing position may remain held.

Selection and sizing are unchanged from ``hyper-ai-v5.py``. What changes is how
backtests account for the cost of leaving a vault. Hypercore charges a
performance fee on redeemed profit, not a flat fee on redeemed capital, so v5's
single 10 BPS exit fee understated the cost of a profitable exit and overstated
the cost of a losing one. This module models both components:

- a **10% performance fee** on positive profit, measured against the position's
  remaining weighted-average cost basis; and
- **10 BPS of the gross redeemed capital**, charged on every redemption
  including loss-making ones.

Because the fee depends on the position's own cost basis rather than on the
pair, it is refreshed for every open vault position immediately before each
portfolio decision, and open positions are marked at their *net redeemable*
value rather than at gross NAV. Sizing therefore allocates the capital that
could actually be redeemed. Vault redemptions settle asynchronously, so the fee
snapshotted at the redemption request is carried on the pricing model until the
sell settles; otherwise a later settlement would reprice at a stale flat fee.

All of this is backtest-only. Live execution receives the real fee through the
executed redemption price, so charging it again here would double-count it.

The parameters below define the strategy's current configuration. Historical
backtest results are not guarantees of future performance and should be recorded
with the data, assumptions, and code revision used to produce them.

Backtest results (2026-01-03 to 2026-07-08)
===========================================

Last backtest run: 2026-08-21

==================  ==========  ==========  ==========
Metric              Strategy    BTC         ETH
==================  ==========  ==========  ==========
Start Period        2026-01-03  2026-01-03  2026-01-03
End Period          2026-07-08  2026-07-08  2026-07-08
Risk-Free Rate      0.0%        0.0%        0.0%
Time in Market      50.0%       99.0%       99.0%
Cumulative Return   27.76%      -31.79%     -44.56%
CAGR %              61.72%      -52.8%      -68.57%
Sharpe              2.88        -1.28       -1.43
Prob. Sharpe Ratio  99.51%      17.96%      15.34%
Smart Sharpe        2.82        -1.26       -1.41
Sortino             6.47        -1.73       -1.94
Smart Sortino       6.34        -1.69       -1.9
Sortino/sqrt(2)     4.58        -1.22       -1.37
Smart Sortino/sq2   4.48        -1.2        -1.34
Omega               2.14        2.14        2.14
Max Drawdown        -4.44%      -39.63%     -53.35%
==================  ==========  ==========  ==========

Read these figures with the concentration the run carries. Five positions account
for most of the net profit, 40 of 75 positions deploy more than 20% of the target
vault's TVL, and deposits and redemptions are modelled as filling at vault NAV
subject to the 33% pool cap. Daily returns are markedly right-tailed (skew 3.2,
kurtosis 21.3), so the result depends on a small number of days.
"""

#
# Imports
#

import datetime
import logging
import math
from dataclasses import replace
from types import MethodType

import pandas as pd
from eth_defi.token import USDC_NATIVE_TOKEN
from plotly.graph_objects import Figure
from tradingstrategy.chain import ChainId
from tradingstrategy.timebucket import TimeBucket
from tradingstrategy.utils.forward_fill import forward_fill
from tradingstrategy.utils.token_filter import filter_for_selected_pairs

from tradeexecutor.curator import (build_hyperliquid_vault_universe,
                                   is_quarantined)
from tradeexecutor.exchange_account.allocation import (
    calculate_portfolio_target_value, get_redeemable_portfolio_capital)
from tradeexecutor.state.identifier import (AssetIdentifier,
                                            TradingPairIdentifier)
from tradeexecutor.state.position import TradingPosition
from tradeexecutor.state.trade import TradeExecution
from tradeexecutor.state.types import Percent, USDollarAmount
from tradeexecutor.strategy.alpha_model import AlphaModel
from tradeexecutor.strategy.chart.definition import (ChartInput, ChartKind,
                                                     ChartRegistry)
from tradeexecutor.strategy.chart.standard.alpha_model import (
    alpha_model_diagnostics, skipped_signals)
from tradeexecutor.strategy.chart.standard.equity_curve import \
    equity_curve as equity_curve_chart
from tradeexecutor.strategy.chart.standard.equity_curve import \
    equity_curve_with_drawdown
from tradeexecutor.strategy.chart.standard.interest import vault_statistics
from tradeexecutor.strategy.chart.standard.performance_metrics import \
    performance_metrics
from tradeexecutor.strategy.chart.standard.position import positions_at_end
from tradeexecutor.strategy.chart.standard.profit_breakdown import \
    trading_pair_breakdown
from tradeexecutor.strategy.chart.standard.thinking import last_messages
from tradeexecutor.strategy.chart.standard.trading_metrics import \
    trading_metrics
from tradeexecutor.strategy.chart.standard.trading_universe import (
    available_trading_pairs, inclusion_criteria_check)
from tradeexecutor.strategy.chart.standard.vault import all_vault_positions
from tradeexecutor.strategy.chart.standard.weight import (
    equity_curve_by_asset, equity_curve_by_chain, weight_allocation_statistics)
from tradeexecutor.strategy.cycle import CycleDuration
from tradeexecutor.strategy.default_routing_options import TradeRouting
from tradeexecutor.strategy.execution_context import (ExecutionContext,
                                                      ExecutionMode)
from tradeexecutor.strategy.pandas_trader.indicator import (
    IndicatorDependencyResolver, IndicatorSource)
from tradeexecutor.strategy.pandas_trader.indicator_decorator import \
    IndicatorRegistry
from tradeexecutor.strategy.pandas_trader.strategy_input import StrategyInput
from tradeexecutor.strategy.pandas_trader.trading_universe_input import \
    CreateTradingUniverseInput
from tradeexecutor.strategy.parameters import StrategyParameters
from tradeexecutor.strategy.tag import StrategyTag
from tradeexecutor.strategy.trading_strategy_universe import (
    TradingStrategyUniverse, load_partial_data,
    load_vault_universe_with_metadata)
from tradeexecutor.strategy.tvl_size_risk import USDTVLSizeRiskModel
from tradeexecutor.strategy.universe_model import UniverseOptions
from tradeexecutor.strategy.weighting import weight_passthrouh
from tradeexecutor.utils.dedent import dedent_any

logger = logging.getLogger(__name__)


#
# Trading universe constants
#

trading_strategy_engine_version = "0.5"

CHAIN_ID = ChainId.hyperliquid

EXCHANGES = ("uniswap-v2", "uniswap-v3")

SUPPORTING_PAIRS = [
    (ChainId.arbitrum, "uniswap-v3", "WETH", "USDC", 0.0005),
    (ChainId.ethereum, "uniswap-v3", "WETH", "USDC", 0.0005),
    (ChainId.ethereum, "uniswap-v3", "WBTC", "USDC", 0.003),
]

LENDING_RESERVES = None

PREFERRED_STABLECOIN = AssetIdentifier(
    chain_id=ChainId.hyperliquid.value,
    address=USDC_NATIVE_TOKEN[ChainId.hyperliquid].lower(),
    token_symbol="USDC",
    decimals=6,
)

ALLOWED_VAULT_DENOMINATION_TOKENS = {"USDC", "USDT", "USDC.e", "crvUSD", "USDT0", "USD₮0", "USDt", "USDS"}

BENCHMARK_PAIRS = SUPPORTING_PAIRS

#: Vaults excluded by hand because their price series cannot support a position.
#:
#: This is a data-quality exclusion, not a performance judgement. A vault belongs here when its
#: share price is small enough that the feed's precision, rather than the vault's trading, drives
#: the returns the strategy sees.
#:
#: Keyed on pool address rather than name: token symbols are truncated to ten characters in this
#: universe, so names are not unique and not stable.
MANUAL_BLACKLIST = {
    # Scared Money - share price 0.00000800 to 0.00005568 across 2026, one significant figure of
    # precision, moving +-20% a day on rounding rather than performance. A $10,781 position taken
    # against $28,654 of vault TVL (38% of the vault) and exited at +89% contributed ~6 pp of
    # headline CAGR to an earlier research configuration, which the fill-at-NAV assumption makes
    # free.
    "0x5290ab34acb59cfe1371baa5782eba14433d308f",
}

#: Vaults masked for a counterfactual experiment. **Not a blacklist.**
#:
#: **Empty.** One vault was masked across several research notebooks to measure how much of the
#: chain's results depended on it; that question is answered and the mask is lifted. Its price data
#: was never in doubt - a dense daily series corroborated by TVL growth as outside money chased the
#: same run.
#:
#: The machinery is kept rather than deleted so re-masking is a one-line change rather than an edit
#: of :py:func:`decide_trades`. An empty set means every vault that passes the blacklist is tradable.
#:
#: The distinction from :py:data:`MANUAL_BLACKLIST` is deliberate: a blacklist entry says a vault can
#: never be traded, a mask says "run the counterfactual without it and see what changes".
MASKED_VAULTS: set[str] = set()

#
# Strategy parameters
#


class Parameters:
    #: Strategy module identifier for the CAGR+Sortino inverse-variance variant,
    #: with Hypercore's redemption performance fee modelled in backtests.
    id = "hyper-ai-v6-cagr-sortino-inverse-variance"

    #: Daily candles match the whole Hyperliquid survivor-first research chain.
    candle_time_bucket = TimeBucket.d1
    #: Native 2-day rebalance cadence. Three separate cadence studies settled on 48 hours, and a
    #: native `cycle_2d` was verified to reproduce the earlier modulo-counter version to within
    #: 0.36 pp, so the cadence is pinned here and the modulo counter is retired.
    cycle_duration = CycleDuration.cycle_2d
    #: HyperEVM is the primary chain for vaults.
    chain_id = CHAIN_ID
    #: HyperEVM is the primary execution chain.
    primary_chain_id = CHAIN_ID
    #: Same exchange set as the release-candidate notebooks.
    exchanges = EXCHANGES

    #: **Searched.** The breadth axis is not monotone and the optimum depends on the objective:
    #: 6 is best on CAGR and 8 best on Sharpe/Calmar/drawdown at 150,000, with a reproducible
    #: hole at 7.
    #: An interior optimum confirmed from both sides: mean CAGR 5 -> 20.82%, 6 -> 21.89%,
    #: 7 -> 15.27%, 8 -> 15.60%. Opening the range down to 5 confirmed 6 rather than moving
    #: the optimum.
    max_assets_in_portfolio = 6
    #: 98% target deployment, the validated release default.
    allocation_pct = 0.98
    #: **Searched.** The one parameter known to reverse with capital: 33% beats 50% at 25,000 and
    #: 50% beats 33% at 150,000. At large size the ceiling stops being risk control and becomes a
    #: deployment constraint; 0.33 keeps it as risk control.
    max_concentration_pct = 0.33
    #: **Searched.** The best-evidenced lever in the chain: 15% -> 25% was worth +4.61 pp at 75,000
    #: and +20.34 pp at 150,000, and a gain was confirmed in all six combinations tested. 0.33
    #: extends past anything measured before this configuration.
    per_position_cap_of_pool_pct = 0.33
    #: Engine hygiene threshold for cleaning up tiny residual positions.
    min_portfolio_weight_pct = 0.005

    #: Retired: the cadence is now the engine's own 2-day cycle, so every cycle acts.
    rebalance_every_n_cycles = 1
    #: Retired from the search: measured inert at or below the cadence and harmful above it, and
    #: no configuration wanted it.
    minimum_hold_days = 1

    #: Hyperliquid has a hard 5 USD minimum deposit.
    absolute_min_vault_deposit_usd = 5.0
    #: Buy threshold as a fraction of initial cash. Deposits are free under an exit-only
    #: fee, so the buy side stays tight and is not searched.
    individual_rebalance_min_threshold_of_initial_cash_pct = 0.0005
    #: **Searched.** Sell-side softband as a fraction of initial cash: **0.5%**, i.e. $750 at the
    #: 150,000 bankroll this configuration was tuned for.
    #:
    #: Expressed as a fraction rather than a constant because the failure mode scales with capital:
    #: the ladder stopped at 1%, and the crash boundary is a *fraction* of capital - between 1% and
    #: 2% at every bankroll tested - because suppressed sells are not deducted from the buy budget,
    #: so a wide band lets the plan spend proceeds from a trim it then cancels. $3,000 (2%) and
    #: $6,000 (4%) both fail at 150,000 while $1,500 (1%) survives. 1% is the edge rather than a safe
    #: margin, so this sits a factor of two below it - and stays a factor of two below it if the
    #: bankroll is changed.
    sell_rebalance_min_threshold_of_initial_cash_pct = 0.005

    #: Survivor-first TVL floor.
    min_tvl_usd = 7_500

    #: Trailing window (days) for the CAGR leg of the composite selection score.
    #: Pinned: shortening it to 270 days costs -21% CAGR.
    cagr_lookback_days = 360
    #: Trailing window (days) for the risk leg of the composite selection score.
    #: Bottom boundary of the searched range, with a 20 pp cliff up to 60 and 30 untested,
    #: so whether shorter is better remains open.
    sharpe_lookback_days = 45
    #: Composite blend: ``cagr_weight x CAGR score + (1 - cagr_weight) x risk score``.
    #: Both legs are load-bearing: either leg alone is materially worse on both CAGR and drawdown.
    #: A value no earlier search in this chain contained - the predecessor ran 0.3/0.5/0.7 and
    #: settled on 0.5, stepping straight over the optimum. All 15 top configurations of the final
    #: search use 0.6, at 100% consensus.
    #:
    #: This is the top of the searched range and 0.7 was dropped from it on a marginal mean measured
    #: under different companion values, so whether 0.7 is better is still open.
    cagr_weight = 0.60
    #: Momentum gate window: a vault is eligible only while its trailing return over
    #: this window exceeds :py:attr:`gate_threshold`. Pure drawdown insurance at
    #: near-zero return cost (removal: -4.5pp CAGR and max DD -7.4% -> -17.5%).
    gate_lookback_days = 14
    #: Momentum gate threshold (fraction).
    #: This axis is inert across a range 50% wider than the earlier search: mean CAGR spans only
    #: 17.52% to 19.54% across all six searched values, and the two newly opened permissive values
    #: changed nothing.
    gate_threshold = -0.16
    #: **Searched.** The window for the volatility estimate that becomes the sizing weight.
    #:
    #: A genuine interior optimum: mean CAGR 60 -> 18.44%, 75 -> 19.93%, 90 -> 21.03%,
    #: 105 -> 16.04%. The collapse at 105 is what makes 90 interior rather than a boundary artefact.
    #:
    #: Chosen over the higher-scoring window-60 configurations deliberately. Those returned up to
    #: 34.12% but their worst one-step neighbour falls to 11-12%, because 60 is a boundary and the
    #: step off it lands on the sharpe_lookback_days cliff. At 90 the worst neighbour holds at
    #: 31.69%.
    inverse_vol_window = 90

    #: Selection score. ``cagr_sortino_weight`` replaces the Sharpe leg of the incumbent composite
    #: with Sortino, measured at +0.869 Sharpe on a single configuration and carried through a
    #: search here.
    #:
    #: The motivation is that the vaults responsible for most of the strategy's losses share a
    #: signature the Sharpe leg cannot see: they almost never post a down day, which inflates their
    #: trailing Sharpe rather than deflating it. The Sharpe leg separates losers from winners at
    #: 0.038 - no better than chance.
    selection_score_indicator = "cagr_sortino_weight"

    #: Sizing rule. Size by 1/sigma^2 rather than 1/sigma: +2.77 pp of CAGR at 60 days, winning at
    #: 7 of 7 lookbacks on both the full window and clean daily data, with a genuine interior
    #: optimum at k=2. The gain is return-only - Sharpe moved +0.02 and drawdown 0.57 pp worse.
    #:
    #: Note the ranking between inverse variance and inverse volatility **reverses with the
    #: concentration cap** - inverse variance wins at 33% and loses at 25% - so it is tied to
    #: :py:attr:`max_concentration_pct` above rather than independently true.
    weighting_method = "inverse_variance"

    #: Unused while :py:attr:`weighting_method` is ``inverse_variance``; kept so the sizing helper
    #: has a value for every branch.
    weighting_exponent = 2.0
    softmax_temperature = 0.25

    #: Start trading from January 2026 while retaining pre-start indicator history.
    backtest_start = datetime.datetime(2026, 1, 1)
    #: Exclusive end boundary.
    backtest_end = datetime.datetime(2026, 7, 10)
    #: Release-candidate bankroll.
    initial_cash = 150_000
    #: Derived at class creation time from the configured initial cash and the 5 USD hard floor.
    individual_rebalance_min_threshold_usd = max(
        absolute_min_vault_deposit_usd,
        initial_cash * individual_rebalance_min_threshold_of_initial_cash_pct,
    )
    #: Derived at class creation time from the configured initial cash and the 5 USD hard floor.
    sell_rebalance_min_threshold_usd = max(
        absolute_min_vault_deposit_usd,
        initial_cash * sell_rebalance_min_threshold_of_initial_cash_pct,
    )

    #: Backtests do not model managed yield.
    use_managed_yield = False

    #: Margin withheld from the same-cycle buy budget by the synchronous cash cap
    #: (``cap_buys_to_sync_cash``): sell proceeds are sized mark-to-market but
    #: execution realises slightly less (fees, price impact, raw-unit rounding).
    #: Derived once at class creation from ``initial_cash`` so it scales across
    #: configured bankrolls -- it does not track intra-run treasury growth.
    sync_cash_headroom_usd = max(0.50, initial_cash * 0.0005)

    #: Default routing is still required by the strategy runtime even though it is not the alpha source.
    routing = TradeRouting.default
    #: Set deliberately high so live and notebook indicator calculations use effectively
    #: all available history; history-derived indicators would be silently biased by a
    #: truncated lookback window. The CAGR (360-day), Sortino (45-day), inverse-vol (90-day) and
    #: gate (14-day) indicators all depend on the first available data point.
    required_history_period = datetime.timedelta(days=365 * 20)
    #: Same live-style slippage assumption as the release-candidate notebooks.
    slippage_tolerance_pct = 0.0060
    #: Performance fee charged by Hypercore on positive profit at redemption, measured
    #: against the position's remaining weighted-average cost basis. Position-specific
    #: rather than pair-specific, so it is recomputed on every cycle by
    #: :py:func:`refresh_vault_redemption_accounting` instead of being baked into the
    #: universe. Backtest-only: live trading pays the real fee through the executed price.
    vault_performance_fee = 0.10
    #: Fixed fee charged on the gross capital of every redemption, loss-making ones
    #: included. Applied to the trading universe as a sell-side token tax in backtests,
    #: see :py:func:`apply_vault_redemption_capital_fee`, and used as the fallback rate
    #: for any vault pair that has not yet been through the per-position refresh.
    vault_redemption_capital_fee = 0.0010
    #: Assume no liquidity if there is a gap in TVL data.
    assummed_liquidity_when_data_missings_usd = 0.01


#
# Universe creation
#


def apply_vault_redemption_capital_fee(
    strategy_universe: TradingStrategyUniverse,
    fee: Percent,
) -> int:
    """Charge the fixed redemption-capital fee on the sell side of every vault pair.

    The backtest pricing model resolves a trade fee as ``pair.fee + directional token tax``.
    Vault pairs carry ``pair.fee == 0``, so a ``sell_tax`` on the vault share token makes the
    sell price ``share price x (1 - fee)`` while deposits stay at NAV.

    Because ``BacktestValuationModel`` values open positions at the same sell price, open
    vault positions are also marked *net of the exit fee* rather than at gross NAV. That is
    the conservative reading: reported equity is what could actually be redeemed.

    This covers only the capital component of the redemption cost. The performance-fee
    component depends on the position's remaining cost basis rather than on the pair, so it
    cannot be set here; :py:func:`refresh_vault_redemption_accounting` recomputes the
    combined rate per open position before each portfolio decision. The rate written here is
    what a vault pair carries until it is first held.

    :param strategy_universe:
        Universe to mutate in place. Pair objects are cached by
        :py:attr:`TradingStrategyUniverse.pair_cache`, so the mutation is visible to the
        pricing and valuation models during the backtest.

    :param fee:
        Capital fee as a fraction, e.g. ``0.0010`` for 10 BPS.

    :return:
        Number of vault pairs that received the fee.
    """
    assert 0 <= fee < 0.10, f"Exit fee does not look real: {fee}"

    count = 0
    for pair in strategy_universe.iterate_pairs():
        if not pair.is_vault():
            continue
        assert pair.can_have_tax(), f"Vault pair cannot carry a sell-side fee: {pair}"
        pair.base.other_data["sell_tax"] = fee
        count += 1
    return count


def create_trading_universe(
    input: CreateTradingUniverseInput,
) -> TradingStrategyUniverse:
    """Create the trading universe.

    Keep the backtest trading window fixed to ``Parameters.backtest_start`` /
    ``Parameters.backtest_end``, but let ``required_history_period`` extend the
    data-loading window backwards so age and other history-derived indicators
    can see the full pre-backtest history for the selected vaults.
    """
    execution_context = input.execution_context
    client = input.client
    timestamp = input.timestamp
    parameters = input.parameters or Parameters
    universe_options = input.universe_options

    debug_printer = logger.info if execution_context.live_trading else print

    chain_id = parameters.primary_chain_id

    # Supporting benchmark pairs live on Uniswap on Ethereum and Arbitrum.
    # We only need them for backtest benchmarking and research visualisations.
    # In live-style runs such as trade execution, one-off diagnostics, and
    # Lagoon deployment, we do not trade these pairs at all.
    # Loading them anyway makes the universe look multichain to later routing
    # and deployment code, even though the executable strategy is Hyperliquid
    # vault-only on HyperEVM.
    # That false multichain signal is what caused Lagoon deployment to demand
    # an Ethereum Web3 connection for a Hyperliquid-only deployment.
    if execution_context.live_trading:
        supporting_pairs = []
    else:
        supporting_pairs = SUPPORTING_PAIRS

    debug_printer(f"Preparing trading universe on chain {chain_id.get_name()}")

    all_pairs_df = client.fetch_pair_universe().to_pandas()
    # Filter the benchmark pairs only when we are in backtesting / research.
    # In live paths this intentionally becomes an empty frame, because the
    # strategy obtains its real tradeable instruments from the vault universe
    # loaded below, not from spot benchmark pairs.
    pairs_df = filter_for_selected_pairs(all_pairs_df, supporting_pairs)
    debug_printer(f"We have total {len(all_pairs_df)} pairs in dataset and going to use {len(pairs_df)} pairs for the strategy")

    # Full, non-survivorship-biased universe: `top_n=None` still applies the
    # 120-vault CAGR cap baked into `CHAIN_CONFIG[9999]["top_n"]`, so pass an
    # explicit oversized `top_n` to keep every eligible Hyperliquid vault.
    source_vaults = build_hyperliquid_vault_universe(
        min_tvl=parameters.min_tvl_usd,
        min_age=0.0,
        top_n=9999,
    )
    vault_universe = load_vault_universe_with_metadata(
        client,
        vaults=source_vaults,
        check_all_vaults_found=False,
    )
    metadata_vault_specs = {
        (ChainId(vault.chain_id), vault.vault_address.lower())
        for vault in vault_universe.iterate_vaults()
    }
    missing_source_vaults = [
        (chain_id, address)
        for chain_id, address in source_vaults
        if (chain_id, address.lower()) not in metadata_vault_specs
    ]
    if missing_source_vaults:
        missing_vault_list = ", ".join(f"{chain_id.value}:{address}" for chain_id, address in missing_source_vaults)
        debug_printer(f"Skipped {len(missing_source_vaults)} Hypercore vaults missing from remote vault metadata: {missing_vault_list}")
    vault_universe = vault_universe.limit_to_denomination(ALLOWED_VAULT_DENOMINATION_TOKENS, check_all_vaults_found=True)
    debug_printer(f"Loaded {vault_universe.get_vault_count()} vaults from remote vault metadata, source vaults count: {len(source_vaults)}")

    # `load_partial_data()` now honours `required_history_period` in backtests
    # as a loader-window extension instead of clipping history to the trading window.
    dataset = load_partial_data(
        client=client,
        time_bucket=parameters.candle_time_bucket,
        pairs=pairs_df,
        execution_context=execution_context,
        universe_options=universe_options,
        liquidity=True,
        liquidity_time_bucket=TimeBucket.d1,
        lending_reserves=LENDING_RESERVES,
        vaults=vault_universe,
        vault_history_source="trading-strategy-website",
        check_all_vaults_found=True,
    )

    strategy_universe = TradingStrategyUniverse.create_from_dataset(
        dataset,
        reserve_asset=PREFERRED_STABLECOIN,
        forward_fill=True,
        forward_fill_until=timestamp,
        primary_chain=parameters.primary_chain_id,
    )

    # Backtests would otherwise redeem at NAV, which overstates the return of a strategy
    # that turns over many multiples of its capital through vault redemptions.
    # Live execution gets the real fee from the executed price, so it must not be applied
    # a second time here.
    if not execution_context.live_trading:
        fee = float(parameters.vault_redemption_capital_fee)
        vault_pair_count = apply_vault_redemption_capital_fee(strategy_universe, fee)
        debug_printer(
            f"Applied a {fee * 10_000:.0f} BPS redemption capital fee to {vault_pair_count} vault pairs; "
            f"performance fees are refreshed per position on every cycle"
        )

    return strategy_universe


def _get_available_supporting_pair_ids(
    strategy_universe: TradingStrategyUniverse,
) -> set[int]:
    """Return supporting pair ids that are actually present in the universe."""
    pair_ids = set()

    # Live universes intentionally skip SUPPORTING_PAIRS above.
    # Because of that, any later benchmark lookup must tolerate the pairs being
    # absent instead of crashing.
    # We resolve only the pairs that are really present, so both backtest and
    # live code paths can share the same indicator helpers safely.
    #
    # get_pair_by_human_description() raises:
    # - KeyError when the pair itself is missing from the universe
    # - RuntimeError when the exchange (e.g. uniswap-v3) is not in the universe at all
    for desc in SUPPORTING_PAIRS:
        try:
            pair_ids.add(strategy_universe.get_pair_by_human_description(desc).internal_id)
        except (KeyError, RuntimeError):
            continue
    return pair_ids


#
# Strategy logic
#


def compute_sizing_weights(
    selected_pair_ids: list[int],
    inv_vol_by_id: dict[int, float],
    signal_by_id: dict[int, float],
    method: str,
    softmax_temperature: float,
    weighting_exponent: float = 2.0,
) -> dict[int, float]:
    """Turn per-vault statistics into portfolio sizing weights.

    Selection is unchanged across every method - the basket always contains the same vaults,
    ranked by the composite selection score. Only the size of each slot differs, which
    isolates the sizing decision from the selection decision.

    :param selected_pair_ids:
        The vaults that will be held this cycle, in ranked order.

    :param inv_vol_by_id:
        ``1 / sigma`` per vault, where sigma is rolling daily-return volatility.

    :param signal_by_id:
        The composite selection score per vault, bounded to ``[0, 1]``.

    :return:
        Raw weights per pair id. `AlphaModel.normalise_weights()` rescales them, so only the
        relative values matter and they need not sum to one.
    """

    def _normalised(values: dict[int, float]) -> dict[int, float]:
        total = sum(values.values())
        if total <= 0:
            return {pair_id: 1.0 / len(values) for pair_id in values}
        return {pair_id: value / total for pair_id, value in values.items()}

    if not selected_pair_ids:
        return {}

    inv_vol = {pair_id: max(inv_vol_by_id.get(pair_id, 0.0), 0.0) for pair_id in selected_pair_ids}
    composite = {pair_id: max(signal_by_id.get(pair_id, 0.0), 0.0) for pair_id in selected_pair_ids}

    if method == "equal":
        return {pair_id: 1.0 for pair_id in selected_pair_ids}

    if method == "inverse_vol":
        return inv_vol

    if method == "inverse_variance":
        # inv_vol is 1/sigma, so squaring gives 1/sigma^2
        return {pair_id: value ** 2 for pair_id, value in inv_vol.items()}

    if method == "inverse_power":
        # w ~ sigma^-k for an arbitrary k. inv_vol is already 1/sigma, so raise it to k.
        # k=1 is inverse_vol, k=2 is inverse_variance; k<1 flattens toward equal weight.
        return {pair_id: value ** weighting_exponent for pair_id, value in inv_vol.items()}

    if method == "composite":
        # A vault can score 0 on the composite and still be selected; fall back to equal weight
        # rather than handing the whole basket to one name on a degenerate cycle.
        if sum(composite.values()) <= 0:
            return {pair_id: 1.0 for pair_id in selected_pair_ids}
        return composite

    if method == "softmax":
        tau = max(softmax_temperature, 1e-6)
        top = max(composite.values())
        # Subtract the max before exponentiating for numerical stability.
        return {pair_id: math.exp((value - top) / tau) for pair_id, value in composite.items()}

    if method == "blend":
        inv_vol_norm = _normalised(inv_vol)
        composite_norm = _normalised(composite)
        blended = {pair_id: inv_vol_norm[pair_id] * composite_norm[pair_id] for pair_id in selected_pair_ids}
        if sum(blended.values()) <= 0:
            return inv_vol
        return blended

    raise ValueError(f"Unknown weighting method: {method}")


def install_vault_redemption_pricing(
    pricing_model,
    capital_fee_rate: Percent,
) -> None:
    """Make backtest vault settlements retain the fee set at redemption request time.

    Async vault redemptions are priced when they settle, not when the request is made, and
    the settlement resolves its own :py:class:`TradingPairIdentifier` object. A ``sell_tax``
    written onto the pair we held at request time is therefore not guaranteed to still be
    visible at settlement, which would silently reprice the redemption at the flat capital
    fee and refund the performance fee.

    Keep the per-pair rate on the pricing model instead, which survives the whole backtest,
    and wrap ``get_sell_price()`` so every vault sell is quoted net of the snapshotted rate.

    Idempotent: repeated calls leave the first wrapper in place rather than stacking fees.

    :param pricing_model:
        Backtest pricing model to patch in place.

    :param capital_fee_rate:
        Fallback rate for a vault pair that has not been through
        :py:func:`refresh_vault_redemption_accounting` yet.
    """
    if hasattr(pricing_model, "_vault_redemption_fee_by_pair_id"):
        return

    original_get_sell_price = pricing_model.get_sell_price

    def get_sell_price_with_redemption_fee(self, ts, pair, quantity):
        pricing = original_get_sell_price(ts, pair, quantity)
        if not pair.is_vault():
            return pricing
        fee = self._vault_redemption_fee_by_pair_id.get(pair.internal_id, capital_fee_rate)
        reserve = float(quantity) * pricing.mid_price
        return replace(
            pricing,
            price=float(pricing.mid_price * (1.0 - fee)),
            lp_fee=[reserve * fee],
            pair_fee=[fee],
            token_tax=reserve * fee,
            token_tax_percent=fee,
        )

    pricing_model._vault_redemption_fee_by_pair_id = {}
    pricing_model.get_sell_price = MethodType(get_sell_price_with_redemption_fee, pricing_model)


def get_remaining_cost_basis(position: TradingPosition) -> USDollarAmount:
    """Calculate the weighted-average cost basis of the vault shares still held.

    Successful sells reduce the historical cost basis pro rata, while later deposits add
    their actual execution cost. This is the cost base against which Hypercore's
    redemption-time performance fee is charged, so a position that has already been trimmed
    is not charged twice on the profit it already realised.

    :param position:
        Open vault position. Only successfully executed trades count.

    :return:
        Cost basis in USD of the quantity still open.
    """
    quantity = 0.0
    cost_basis = 0.0
    for trade in sorted(position.get_successful_trades(), key=lambda trade: trade.executed_at):
        trade_quantity = abs(float(trade.get_position_quantity()))
        if trade.is_buy():
            quantity += trade_quantity
            cost_basis += trade_quantity * float(trade.executed_price)
        elif trade.is_sell():
            assert quantity > 0, f"Cannot sell without a cost basis: {trade}"
            sold_quantity = min(trade_quantity, quantity)
            cost_basis *= (quantity - sold_quantity) / quantity
            quantity -= sold_quantity

    assert abs(quantity - float(position.get_quantity())) < 1e-8, (
        f"Cost-basis quantity mismatch for {position}: {quantity} vs {position.get_quantity()}"
    )
    return cost_basis


def refresh_vault_redemption_accounting(input: StrategyInput) -> pd.DataFrame:
    """Apply redemption performance fees and revalue every open vault position.

    **Backtest-only.** Live execution receives the real fee through the executed redemption
    price, so applying it here as well would double-count it.

    Hypercore takes the performance fee only from positive redeemed profit, which makes the
    effective fee a function of the position rather than of the pair. The pricing model
    accepts a sell-side tax, so the dollar fee is converted to a per-share rate at the
    decision timestamp and combined with the flat capital fee. Revaluing the position at
    that net price makes allocation size against redeemable, rather than gross, equity.

    :param input:
        Strategy input for this cycle. Open vault positions and the pricing model are
        mutated in place.

    :return:
        One row per revalued vault position, for diagnostics.
    """
    parameters = input.parameters
    timestamp = input.timestamp
    revaluation_timestamp = timestamp.to_pydatetime() if hasattr(timestamp, "to_pydatetime") else timestamp
    pricing_model = input.pricing_model
    capital_fee_rate = float(parameters.vault_redemption_capital_fee)
    performance_fee_rate = float(parameters.vault_performance_fee)
    install_vault_redemption_pricing(pricing_model, capital_fee_rate)

    rows = []
    for position in input.state.portfolio.get_open_positions():
        if not position.pair.is_vault():
            continue

        quantity = float(position.get_quantity())
        if quantity <= 0:
            continue

        gross_price = float(
            pricing_model.get_sell_price(timestamp, position.pair, position.get_quantity()).mid_price
        )
        gross_value = quantity * gross_price
        cost_basis = get_remaining_cost_basis(position)
        performance_fee_usd = max(gross_value - cost_basis, 0.0) * performance_fee_rate
        performance_fee_rate_of_value = performance_fee_usd / gross_value if gross_value else 0.0
        effective_sell_tax = capital_fee_rate + performance_fee_rate_of_value
        assert 0 <= effective_sell_tax < 1, (
            f"Invalid effective redemption fee {effective_sell_tax:.2%} for {position}"
        )

        # Write the rate to the position's own pair, to the universe's cached pair, and to
        # the pricing model. The first two keep synchronous valuation consistent; the third
        # is what an asynchronous settlement on a later cycle reads.
        position.pair.base.other_data["sell_tax"] = effective_sell_tax
        input.strategy_universe.get_pair_by_id(position.pair.internal_id).base.other_data["sell_tax"] = effective_sell_tax
        pricing_model._vault_redemption_fee_by_pair_id[position.pair.internal_id] = effective_sell_tax

        net_price = gross_price * (1.0 - effective_sell_tax)
        position.revalue_base_asset(revaluation_timestamp, net_price)

        rows.append({
            "Position id": position.position_id,
            "Vault": position.pair.base.token_symbol,
            "Gross redeemable value": gross_value,
            "Remaining cost basis": cost_basis,
            "Performance fee accrued": performance_fee_usd,
            "Capital fee accrued": gross_value * capital_fee_rate,
            "Net redeemable value": gross_value - performance_fee_usd - gross_value * capital_fee_rate,
        })

    return pd.DataFrame(rows)


def minimum_hold_protected_pair_ids(
    input: StrategyInput,
    candidate_pair_ids: set[int],
) -> set[int]:
    """Find open positions that are too young to give up their basket slot.

    Only positions whose pair is still an eligible candidate are protected. Vaults
    dropped by the momentum gate, by the quarantine list, or by the bad-pair list never
    reach ``candidate_pair_ids`` and are therefore always free to be sold.

    :param candidate_pair_ids:
        Pair ids that survived selection filtering on this cycle.

    :return:
        Pair ids whose position age is below ``minimum_hold_days``.
    """
    timestamp = input.timestamp
    current_dt = timestamp.to_pydatetime() if hasattr(timestamp, "to_pydatetime") else timestamp
    threshold = datetime.timedelta(days=int(input.parameters.minimum_hold_days))
    protected = set()
    for position in input.state.portfolio.get_open_positions():
        pair = position.pair
        if pair.is_credit_supply():
            continue
        if pair.internal_id not in candidate_pair_ids:
            continue
        if current_dt - position.opened_at >= threshold:
            continue
        protected.add(pair.internal_id)
    return protected


def decide_trades(input: StrategyInput) -> list[TradeExecution]:
    """Rank by the CAGR+Sortino composite, gate on trailing momentum, size by inverse variance."""
    backtesting = input.execution_context.mode == ExecutionMode.backtesting
    parameters = input.parameters
    max_assets_in_portfolio = int(parameters.max_assets_in_portfolio)
    minimum_hold_days = int(parameters.minimum_hold_days)
    position_manager = input.get_position_manager()
    state = input.state
    timestamp = input.timestamp
    indicators = input.indicators
    strategy_universe = input.strategy_universe

    portfolio = position_manager.get_current_portfolio()

    # Mark open vault positions net of the performance fee that a redemption would incur
    # before anything reads equity, so sizing allocates redeemable rather than gross value.
    # Live execution gets the real fee through the executed price and must not pay it twice.
    redemption_accounting = refresh_vault_redemption_accounting(input) if backtesting else pd.DataFrame()

    equity = portfolio.get_total_equity()
    if backtesting and equity < parameters.initial_cash * 0.10:
        return []

    # No modulo cadence: the engine's own cycle is 2 days, so every cycle rebalances.

    tvl_included_pair_count = indicators.get_indicator_value("tvl_included_pair_count")
    included_pairs = indicators.get_indicator_value("inclusion_criteria", na_conversion=False)
    included_pairs = [] if included_pairs is None else list(included_pairs)
    candidates = []
    inv_vol_by_id = {}
    signal_by_id = {}
    gate_threshold = float(parameters.gate_threshold)
    #: Selection score indicator. Pinned to the Sortino composite; the default keeps the function
    #: usable with the incumbent Sharpe composite.
    try:
        selection_score_indicator = parameters["selection_score_indicator"]
    except (KeyError, TypeError):
        selection_score_indicator = "cagr_sharpe_weight"
    for pair_id in included_pairs:
        pair = strategy_universe.get_pair_by_id(pair_id)
        if not state.is_good_pair(pair) or is_quarantined(pair.pool_address, timestamp):
            continue
        # Manually blacklisted: the price data is too broken to trade at the moment.
        if str(pair.pool_address).lower() in MANUAL_BLACKLIST:
            continue
        # Masked for a counterfactual experiment - see MASKED_VAULTS. Not a permanent exclusion.
        if str(pair.pool_address).lower() in MASKED_VAULTS:
            continue
        # Momentum gate: drop (and therefore sell) any vault whose trailing return is at or
        # below the threshold, so decliners leave the basket instead of being held.
        gate_value = indicators.get_indicator_value("return_gate", pair=pair)
        if gate_value is None or gate_value != gate_value or gate_value <= gate_threshold:
            continue
        composite_signal = indicators.get_indicator_value(selection_score_indicator, pair=pair)
        signal = float(composite_signal) if composite_signal is not None and composite_signal == composite_signal else 0.0
        inv_vol = indicators.get_indicator_value("inverse_vol", pair=pair)
        inv_vol_by_id[pair_id] = float(inv_vol) if inv_vol is not None and inv_vol == inv_vol else 0.0
        signal_by_id[pair_id] = signal
        candidates.append((pair_id, pair, signal))
    if not candidates:
        return []

    # Rank by composite (selection), but SIZE by the configured weighting method.
    ordered = sorted(candidates, key=lambda item: (-item[2], item[0]))

    # Minimum holding period. Move still-eligible incumbents that are younger than
    # `minimum_hold_days` to the front of the ranking so they keep their basket slot ahead
    # of higher-ranked newcomers. This suppresses rank-drift churn only: the momentum gate
    # and the quarantine list already removed their vaults from `candidates` above.
    candidate_pair_ids = {pair_id for pair_id, _pair, _signal in candidates}
    hold_protected_ids = minimum_hold_protected_pair_ids(input, candidate_pair_ids)
    naturally_selected_ids = {pair_id for pair_id, _pair, _signal in ordered[:max_assets_in_portfolio]}
    if hold_protected_ids:
        ordered = (
            [item for item in ordered if item[0] in hold_protected_ids]
            + [item for item in ordered if item[0] not in hold_protected_ids]
        )

    # Vault-closed awareness: walk the ranking and give basket slots only to vaults that
    # can take new deposits. A vault we already hold keeps its slot even with a closed
    # deposit window - closed deposits block new capital, not holding, and the sell side
    # remains governed by the momentum gate. The check is lazy so live execution makes
    # only a handful of `vaultDetails` API calls per cycle.
    held_pair_ids = {
        position.pair.internal_id
        for position in state.portfolio.get_open_positions()
        if not position.pair.is_credit_supply()
    }
    selected = []
    deposit_window_skips = 0
    for candidate in ordered:
        if len(selected) >= max_assets_in_portfolio:
            break
        pair_id, pair, signal = candidate
        if pair_id not in held_pair_ids and not input.pricing_model.can_deposit(timestamp, pair):
            deposit_window_skips += 1
            continue
        selected.append(candidate)

    # Sizing. Selection above ranked on the composite score; this decides how much each chosen
    # vault gets. Weights are computed over the selected set so softmax and blend can normalise
    # across exactly the vaults that will be held.
    weight_by_id = compute_sizing_weights(
        [pair_id for pair_id, _pair, _signal in selected],
        inv_vol_by_id,
        signal_by_id,
        method=str(parameters.weighting_method),
        softmax_temperature=float(parameters.softmax_temperature),
        weighting_exponent=float(parameters.weighting_exponent),
    )

    alpha_model = AlphaModel(
        timestamp,
        close_position_weight_epsilon=parameters.min_portfolio_weight_pct,
    )
    for pair_id, pair, signal in selected:
        alpha_model.set_signal(pair, weight_by_id.get(pair_id, 0.0))
    alpha_model.select_top_signals(count=len(selected))
    alpha_model.assign_weights(method=weight_passthrouh)

    redeemable_capital = get_redeemable_portfolio_capital(position_manager)
    portfolio_target_value = calculate_portfolio_target_value(position_manager, parameters.allocation_pct)
    size_risk_model = USDTVLSizeRiskModel(
        pricing_model=input.pricing_model,
        per_position_cap=float(parameters.per_position_cap_of_pool_pct),
    )
    alpha_model.normalise_weights(
        investable_equity=portfolio_target_value,
        size_risk_model=size_risk_model,
        max_weight=float(parameters.max_concentration_pct),
        max_positions=max_assets_in_portfolio,
        waterfall=False,
    )
    alpha_model.update_old_weights(state.portfolio, ignore_credit=False)
    alpha_model.calculate_target_positions(position_manager)
    trades = alpha_model.generate_rebalance_trades_and_triggers(
        position_manager,
        min_trade_threshold=parameters.individual_rebalance_min_threshold_usd,
        individual_rebalance_min_threshold=parameters.individual_rebalance_min_threshold_usd,
        sell_rebalance_min_threshold=parameters.sell_rebalance_min_threshold_usd,
        execution_context=input.execution_context,
        # Scale buys down to the sells that actually execute this cycle, so a
        # large underweight buy funded by several sub-threshold trims cannot
        # overspend the reserve (OutOfSimulatedBalance in backtest, a bounced
        # deposit live).
        cap_buys_to_sync_cash=True,
        sync_cash_headroom_usd=parameters.sync_cash_headroom_usd,
    )
    # Snapshot the fee onto the sell itself. An async redemption settles on a later cycle,
    # by which time the position's cost basis - and therefore its performance fee - has
    # moved on, so the rate in force when the redemption was requested has to travel with
    # the trade rather than be recomputed at settlement.
    if backtesting:
        for trade in trades:
            if not (trade.is_sell() and trade.pair.is_vault()):
                continue
            fee = input.pricing_model._vault_redemption_fee_by_pair_id.get(
                trade.pair.internal_id,
                float(parameters.vault_redemption_capital_fee),
            )
            trade.other_data["backtest_vault_redemption_fee"] = fee
            trade.planned_price = trade.planned_mid_price * (1.0 - fee)

    state.visualisation.add_calculations(
        timestamp,
        {"unallocatable_signals": alpha_model.get_unallocatable_signals()},
    )

    if input.is_visualisation_enabled():
        try:
            top_signal = next(iter(alpha_model.get_signals_sorted_by_weight()))
            if top_signal.normalised_weight == 0:
                top_signal = None
        except StopIteration:
            top_signal = None

        rebalance_volume = sum(trade.get_value() for trade in trades)
        report = dedent_any(
            f"""
            Cycle: #{input.cycle}
            Rebalanced: {'👍' if alpha_model.is_rebalance_triggered() else '👎'}
            Open/about to open positions: {len(state.portfolio.open_positions)}
            Max position value change: {alpha_model.max_position_adjust_usd:,.2f} USD
            Rebalance threshold: {alpha_model.position_adjust_threshold_usd:,.2f} USD
            Trades decided: {len(trades)}
            Pairs meeting inclusion criteria: {len(included_pairs)}
            Pairs meeting TVL inclusion criteria: {tvl_included_pair_count}
            Candidate signals created: {len(candidates)}
            Selected survivor signals: {len(alpha_model.signals)}
            Candidates skipped for closed deposit window: {deposit_window_skips}
            Selection score indicator: {selection_score_indicator}
            Weighting method: {parameters.weighting_method}
            Minimum hold days: {minimum_hold_days}
            Signals blocked by minimum hold: {len(hold_protected_ids - naturally_selected_ids)}
            CAGR lookback days: {parameters.cagr_lookback_days}
            Sharpe lookback days: {parameters.sharpe_lookback_days}
            CAGR weight (blend): {parameters.cagr_weight}
            Total equity: {portfolio.get_total_equity():,.2f} USD
            Cash: {position_manager.get_current_cash():,.2f} USD
            Redeemable capital: {redeemable_capital:,.2f} USD
            Pending redemptions: {position_manager.get_pending_redemptions():,.2f} USD
            Vault positions marked net of redemption fees: {len(redemption_accounting)}
            Investable equity: {alpha_model.investable_equity:,.2f} USD
            Accepted investable equity: {alpha_model.accepted_investable_equity:,.2f} USD
            Allocated to signals: {alpha_model.get_allocated_value():,.2f} USD
            Discarded allocation because of lack of lit liquidity: {alpha_model.size_risk_discarded_value:,.2f} USD
            Rebalance volume: {rebalance_volume:,.2f} USD
            """
        )

        if top_signal:
            assert top_signal.position_size_risk
            report += dedent_any(
                f"""
                Top signal pair: {top_signal.pair.get_ticker()}
                Top signal value: {top_signal.signal}
                Top signal weight: {top_signal.raw_weight}
                Top signal weight (normalised): {top_signal.normalised_weight * 100:.2f} % (got {top_signal.position_size_risk.get_relative_capped_amount() * 100:.2f} % of asked size)
                """
            )

        for flag, count in alpha_model.get_flag_diagnostics_data().items():
            report += f"Signals with flag {flag.name}: {count}" + "\n"

        state.visualisation.add_message(timestamp, report)
        state.visualisation.set_discardable_data("alpha_model", alpha_model)

    return trades


#
# Indicators
#

indicators = IndicatorRegistry()


@indicators.define(source=IndicatorSource.tvl)
def tvl(
    close: pd.Series,
    execution_context: ExecutionContext,
    timestamp: pd.Timestamp,
) -> pd.Series:
    if execution_context.live_trading:
        df = pd.DataFrame({"close": close})
        df_ff = forward_fill(
            df,
            Parameters.candle_time_bucket.to_frequency(),
            columns=("close",),
            forward_fill_until=timestamp,
        )
        return df_ff["close"]

    return close.resample("1h").ffill()


@indicators.define(dependencies=(tvl,), source=IndicatorSource.dependencies_only_universe)
def tvl_inclusion_criteria(
    min_tvl_usd: USDollarAmount,
    dependency_resolver: IndicatorDependencyResolver,
) -> pd.Series:
    series = dependency_resolver.get_indicator_data_pairs_combined(tvl)
    mask = series >= min_tvl_usd
    mask_true_values_only = mask[mask]
    return mask_true_values_only.groupby(level="timestamp").apply(
        lambda x: x.index.get_level_values("pair_id").tolist()
    )


@indicators.define(source=IndicatorSource.strategy_universe)
def trading_availability_criteria(
    strategy_universe: TradingStrategyUniverse,
) -> pd.Series:
    candle_series = strategy_universe.data_universe.candles.df["open"]
    return candle_series.groupby(level="timestamp").apply(
        lambda x: x.index.get_level_values("pair_id").tolist()
    )


@indicators.define(
    dependencies=[
        tvl_inclusion_criteria,
        trading_availability_criteria,
    ],
    source=IndicatorSource.strategy_universe,
)
def inclusion_criteria(
    strategy_universe: TradingStrategyUniverse,
    min_tvl_usd: USDollarAmount,
    dependency_resolver: IndicatorDependencyResolver,
) -> pd.Series:
    # Supporting benchmark pairs are for comparison charts only.
    # They must never compete with real vaults for allocation decisions.
    # In live mode they are not loaded at all, so we resolve them defensively.
    benchmark_pair_ids = _get_available_supporting_pair_ids(strategy_universe)

    tvl_series = dependency_resolver.get_indicator_data(
        tvl_inclusion_criteria,
        parameters={"min_tvl_usd": min_tvl_usd},
    )
    trading_availability_series = dependency_resolver.get_indicator_data(trading_availability_criteria)

    df = pd.DataFrame(
        {
            "tvl_pair_ids": tvl_series,
            "trading_availability_pair_ids": trading_availability_series,
        }
    )
    df = df.fillna("").apply(list)

    def _combine(row):
        final_set = set(row["tvl_pair_ids"]) & set(row["trading_availability_pair_ids"])
        return final_set - benchmark_pair_ids

    union_criteria = df.apply(_combine, axis=1)
    full_index = pd.date_range(
        start=union_criteria.index.min(),
        end=union_criteria.index.max(),
        freq=Parameters.candle_time_bucket.to_frequency(),
    )
    return union_criteria.reindex(full_index, fill_value=[])


#: CAGR of +100% (1.0) or more earns the full CAGR sub-score of 1.0.
CAGR_SCORE_CAP = 1.0
#: Annualised Sharpe of 3.0 or more earns the full Sharpe sub-score of 1.0.
SHARPE_SCORE_CAP = 3.0
#: Daily-return annualisation factor for the rolling Sharpe score.
TRADING_DAYS_PER_YEAR = 365.0


@indicators.define()
def cagr_score(close: pd.Series, cagr_lookback_days: int = 180) -> pd.Series:
    """Bounded trailing-CAGR sub-score in ``[0, 1]``.

    Annualise the vault share-price return over the trailing
    ``cagr_lookback_days`` window and map ``0..100%`` CAGR linearly to
    ``0..1``, clipping negative CAGR to ``0`` and CAGR above ``+100%`` to ``1``.
    Timestamps with less than a full window of history are ``NaN``.
    """
    lookback = int(cagr_lookback_days)
    ratio = close / close.shift(lookback)
    cagr = ratio.pow(TRADING_DAYS_PER_YEAR / lookback) - 1.0
    return (cagr / CAGR_SCORE_CAP).clip(lower=0.0, upper=1.0)


@indicators.define()
def sharpe_score(close: pd.Series, sharpe_lookback_days: int = 180) -> pd.Series:
    """Bounded trailing-Sharpe sub-score in ``[0, 1]``.

    Compute the annualised Sharpe ratio of daily share-price returns over the
    trailing ``sharpe_lookback_days`` window and map ``0..3.0`` Sharpe linearly
    to ``0..1``, clipping negative Sharpe to ``0`` and Sharpe above ``3.0`` to
    ``1``. Timestamps with less than a full window of history are ``NaN``.
    """
    lookback = int(sharpe_lookback_days)
    daily_returns = close.pct_change()
    rolling_mean = daily_returns.rolling(lookback, min_periods=lookback).mean()
    rolling_std = daily_returns.rolling(lookback, min_periods=lookback).std()
    sharpe = (rolling_mean / rolling_std.replace(0.0, float("nan"))) * (TRADING_DAYS_PER_YEAR ** 0.5)
    return (sharpe / SHARPE_SCORE_CAP).clip(lower=0.0, upper=1.0)


@indicators.define()
def sortino_score(close: pd.Series, sharpe_lookback_days: int = 180) -> pd.Series:
    """Bounded trailing-Sortino sub-score, the downside-aware counterpart of ``sharpe_score``.

    Identical to :py:func:`sharpe_score` except the denominator is downside deviation
    ``sqrt(mean(min(r, 0)^2))`` rather than total standard deviation.

    The motivation is that the vaults responsible for most of the strategy's losses share a
    signature the Sharpe leg cannot see: they almost never post a down day, which inflates their
    trailing Sharpe rather than deflating it. The Sharpe leg separates losers from winners at
    0.038 - no better than chance. Sortino divides by downside only, so a series with no down days
    yields NaN and drops out of scoring instead of topping it.
    """
    lookback = int(sharpe_lookback_days)
    returns = close.pct_change()
    rolling_mean = returns.rolling(lookback, min_periods=lookback).mean()
    downside = (returns.clip(upper=0.0) ** 2).rolling(lookback, min_periods=lookback).mean() ** 0.5
    sortino = (rolling_mean / downside.replace(0.0, float("nan"))) * (TRADING_DAYS_PER_YEAR ** 0.5)
    return (sortino / SHARPE_SCORE_CAP).clip(lower=0.0, upper=1.0)


@indicators.define(
    dependencies=(cagr_score, sharpe_score),
    source=IndicatorSource.dependencies_only_per_pair,
)
def cagr_sharpe_weight(
    pair: TradingPairIdentifier,
    dependency_resolver: IndicatorDependencyResolver,
    cagr_lookback_days: int = 180,
    sharpe_lookback_days: int = 180,
    cagr_weight: float = 0.5,
) -> pd.Series:
    """``cagr_weight x CAGR score + (1 - cagr_weight) x Sharpe score`` composite.

    The incumbent selection score, kept as the fallback for
    :py:attr:`Parameters.selection_score_indicator`.

    Both sub-scores are already bounded to ``[0, 1]`` so the blend is also in
    ``[0, 1]`` for any ``cagr_weight`` in ``[0, 1]``. A vault must have enough
    history to compute *both* sub-scores for the timestamp (i.e.
    ``max(cagr_lookback_days, sharpe_lookback_days)`` days); otherwise the
    composite is ``NaN`` and the decision function treats the vault as an
    unscored, lowest-priority candidate.
    """
    cagr_component = dependency_resolver.get_indicator_data(
        "cagr_score", pair=pair, parameters={"cagr_lookback_days": cagr_lookback_days},
    )
    sharpe_component = dependency_resolver.get_indicator_data(
        "sharpe_score", pair=pair, parameters={"sharpe_lookback_days": sharpe_lookback_days},
    )
    return cagr_weight * cagr_component + (1.0 - cagr_weight) * sharpe_component


@indicators.define(
    dependencies=(cagr_score, sortino_score),
    source=IndicatorSource.dependencies_only_per_pair,
)
def cagr_sortino_weight(
    pair: TradingPairIdentifier,
    dependency_resolver: IndicatorDependencyResolver,
    cagr_lookback_days: int = 180,
    sharpe_lookback_days: int = 180,
    cagr_weight: float = 0.5,
) -> pd.Series:
    """``cagr_weight x CAGR score + (1 - cagr_weight) x Sortino score``.

    The selection score this strategy uses.

    Both sub-scores are bounded to ``[0, 1]``, so the blend is too. A vault must have enough history
    for both legs, and - unlike the Sharpe composite - must also have posted at least one down day
    inside the Sortino window, otherwise the Sortino leg is NaN and the vault goes unscored.
    """
    cagr_component = dependency_resolver.get_indicator_data(
        "cagr_score",
        pair=pair,
        parameters={"cagr_lookback_days": cagr_lookback_days},
    )
    sortino_component = dependency_resolver.get_indicator_data(
        "sortino_score",
        pair=pair,
        parameters={"sharpe_lookback_days": sharpe_lookback_days},
    )
    return cagr_weight * cagr_component + (1.0 - cagr_weight) * sortino_component


#: Floor on daily volatility so inverse-vol cannot explode for near-flat series.
VOL_FLOOR = 1e-4


@indicators.define()
def inverse_vol(close: pd.Series, inverse_vol_window: int = 60) -> pd.Series:
    """Inverse rolling daily-return volatility (higher = calmer); NaN below the window."""
    lb = int(inverse_vol_window)
    vol = close.pct_change().rolling(lb, min_periods=lb).std()
    return 1.0 / vol.clip(lower=VOL_FLOOR)


@indicators.define()
def return_gate(close: pd.Series, gate_lookback_days: int = 30) -> pd.Series:
    """Raw trailing return over `gate_lookback_days` used as the eligibility gate; NaN below window."""
    lb = int(gate_lookback_days)
    return close / close.shift(lb) - 1.0


@indicators.define(dependencies=(tvl_inclusion_criteria,), source=IndicatorSource.dependencies_only_universe)
def tvl_included_pair_count(
    min_tvl_usd: USDollarAmount,
    dependency_resolver: IndicatorDependencyResolver,
) -> pd.Series:
    series = dependency_resolver.get_indicator_data(
        "tvl_inclusion_criteria",
        parameters={
            "min_tvl_usd": min_tvl_usd,
        },
    )
    return series.apply(len)


def create_indicators(
    timestamp: datetime.datetime | None,
    parameters: StrategyParameters,
    strategy_universe: TradingStrategyUniverse,
    execution_context: ExecutionContext,
):
    """Create indicators for the strategy."""
    return indicators.create_indicators(
        timestamp=timestamp,
        parameters=parameters,
        strategy_universe=strategy_universe,
        execution_context=execution_context,
    )


#
# Charts
#


def equity_curve_with_benchmark(input: ChartInput) -> list[Figure]:
    """Equity curve with ETH benchmark."""
    return equity_curve_chart(
        input,
        benchmark_token_symbols=["ETH"],
    )


def inclusion_criteria_check_with_chain(input: ChartInput) -> pd.DataFrame:
    """Inclusion criteria table with chain shown."""
    return inclusion_criteria_check(
        input,
        show_chain=True,
    )


def trading_pair_breakdown_with_chain(input: ChartInput) -> pd.DataFrame:
    """Trading pair breakdown with chain and address."""
    return trading_pair_breakdown(
        input,
        show_chain=True,
        show_address=True,
    )


def all_vault_positions_by_profit(input: ChartInput) -> pd.DataFrame:
    """Vault positions sorted by profit."""
    return all_vault_positions(
        input,
        sort_by="Profit USD",
        sort_ascending=False,
        show_address=True,
    )


def create_charts(
    timestamp: datetime.datetime | None,
    parameters: StrategyParameters,
    strategy_universe: TradingStrategyUniverse,
    execution_context: ExecutionContext,
) -> ChartRegistry:
    """Define charts we use in backtesting and live trading."""
    # Live universes intentionally omit SUPPORTING_PAIRS above.
    # Keeping them as default chart benchmark lookups would make the webhook
    # chart API try to resolve pairs that are not present in the live universe.
    default_benchmark_pairs = [] if execution_context.live_trading else BENCHMARK_PAIRS
    charts = ChartRegistry(default_benchmark_pairs=default_benchmark_pairs)
    charts.register(available_trading_pairs, ChartKind.indicator_all_pairs)
    charts.register(inclusion_criteria_check_with_chain, ChartKind.indicator_all_pairs)
    charts.register(equity_curve_with_benchmark, ChartKind.state_all_pairs)
    charts.register(equity_curve_with_drawdown, ChartKind.state_all_pairs)
    charts.register(performance_metrics, ChartKind.state_all_pairs)
    charts.register(equity_curve_by_asset, ChartKind.state_all_pairs)
    charts.register(equity_curve_by_chain, ChartKind.state_all_pairs)
    charts.register(weight_allocation_statistics, ChartKind.state_all_pairs)
    charts.register(positions_at_end, ChartKind.state_all_pairs)
    charts.register(last_messages, ChartKind.state_all_pairs)
    charts.register(alpha_model_diagnostics, ChartKind.state_all_pairs)
    charts.register(skipped_signals, ChartKind.state_all_pairs)
    charts.register(trading_pair_breakdown_with_chain, ChartKind.state_all_pairs)
    charts.register(trading_metrics, ChartKind.state_all_pairs)
    charts.register(vault_statistics, ChartKind.state_all_pairs)
    charts.register(all_vault_positions_by_profit, ChartKind.state_all_pairs)
    return charts


#
# Metadata
#

tags = {StrategyTag.beta, StrategyTag.live}

name = "Hyperliquid vault of vaults"

short_description = "AI-driven allocation strategy for Hyperliquid vaults"

icon = ""

long_description = """
# Hyperliquid vault-of-vaults strategy

A diversified yield strategy that allocates across Hyperliquid native vaults using
robustness criteria. By analysing returns of different vaults, the strategy allocates to
vaults that are making profit with smooth returns.

## Strategy features

- Directional, not delta neutral
- Diversify across up to six vaults at a time
- Rank vaults by a blended trailing CAGR and Sortino score, so a vault that never posts a down day cannot flatter its own risk score
- Gate out vaults on a negative trailing momentum turn
- Size positions by inverse variance so calmer vaults get more capital
- Rebalance every two days, so the vault redemption fee is not paid for daily weight drift

## Risk parameters

- Portfolio can have maximum 6 positions at any time - each position is an investment in Hyperliquid vaults. Six was chosen based as it is a threshold at which we can usually find enough good vaults to fill up the positions.
- 98% allocation target - always deploy most of capital.
- 33% maximum portfolio concentration per one allocated vault
- 33% maximum TVL participation of a target vault - don't become the largest allocator in small vaults
- Vaults whose share price is quoted at too few significant figures are excluded by hand, because rounding rather than performance would drive the returns the strategy sees
- This strategy is sensitive to best days of a year: most profits will be done on 5-10 days when there are market events happening and the allocated vaults are correctly positioned. For the remaining year the strategy gives modest results.

This is a high-risk vault. Trading cryptocurrencies is inheritently risky. You may lose money.

## Other notes

- We support both Hyperliquid core (legacy) vaults and HyperEVM vaults, although there are currenty no HyperEVM vaults in the strategy universe for allocation.
"""