Armchair GM
2026-27 The league's active season — trades, cap positions, and scenarios are computed against it.

Documentation The six pillars

Asset management

Draft capital, pick quality, optionality, and exchange value. Provisional exchange value is displayed but excluded while the other inventory weights are renormalized.

Aggregation

The table below is extracted from the pillar's declared metric weights at build time. Open the metric implementation notes and source to inspect its raw inputs, normalization, branches, and fallback handling.

pillar_score = Σ(metric_score × metric_weight)

Metric weights

Metric Declared weight
Draft capital value 35.0%
Pick quality 20.0%
Asset optionality 20.0%
Asset exchange value 25.0%

A scenario with no qualifying transactions keeps neutral exchange value at 50 and the full weights. Otherwise, provisional components, an uncalibrated PTV curve for a player-involving trade, or an uncalibrated transaction-value curve make exchange value provisional. Its weight then becomes zero and the other three weights are divided by their source-derived 75% subtotal. The pillar itself remains evaluated. Comparable State Score instead retains full weights and fixes exchange value at 50.

Implementation

These notes and source files are captured from this deployment's code. Expand a module for its algorithm and branches, or download the Elixir file for local inspection.

asset_management.exElixir

lib/trade_machine/gm_grade_v2/pillars/asset_management.ex

Aggregates AVC's four Asset Management metrics.

An uncalibrated player-involving Asset Exchange Value remains visible but is excluded under D-10. The other three weights are then renormalized from 35/20/20 to exact proportions of their 75% subtotal.

Download source ↓

SHA-256 73818fbc5edfdf3f69a7ec0b8acaa60eac606d66b2e6f9d0748935a107e53dab

defmodule TradeMachine.GMGradeV2.Pillars.AssetManagement do
  @moduledoc """
  Aggregates AVC's four Asset Management metrics.

  An uncalibrated player-involving Asset Exchange Value remains visible but is
  excluded under D-10. The other three weights are then renormalized from
  35/20/20 to exact proportions of their 75% subtotal.
  """

  alias TradeMachine.GMGradeV2.Pillars.AssetManagement.AssetExchangeValue
  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.Result.PillarResult

  @full_weights %{
    draft_capital_value: 0.35,
    pick_quality: 0.20,
    asset_optionality: 0.20,
    asset_exchange_value: 0.25
  }

  @inventory_total 0.75
  @provisional_weights %{
    draft_capital_value: 0.35 / @inventory_total,
    pick_quality: 0.20 / @inventory_total,
    asset_optionality: 0.20 / @inventory_total,
    asset_exchange_value: 0.0
  }

  if abs(Enum.sum(Map.values(@full_weights)) - 1.0) > 1.0e-9 or
       abs(Enum.sum(Map.values(@provisional_weights)) - 1.0) > 1.0e-9 do
    raise "Asset Management weights must sum to 1.0"
  end

  @doc "Aggregates the four canonical metric results in published order."
  @spec score([MetricResult.t()]) :: PillarResult.t()
  def score(
        [
          %MetricResult{key: :draft_capital_value},
          %MetricResult{key: :pick_quality},
          %MetricResult{key: :asset_optionality},
          %MetricResult{key: :asset_exchange_value} = exchange
        ] = metric_results
      ) do
    provisional? = AssetExchangeValue.status(exchange) == :provisional
    weights = if(provisional?, do: @provisional_weights, else: @full_weights)
    scored_results = if(provisional?, do: Enum.take(metric_results, 3), else: metric_results)
    {strongest, weakest} = extrema(scored_results)

    %PillarResult{
      key: :asset_management,
      version: ModelVersion.model_version(),
      score: weighted_score(scored_results, weights),
      metric_results: metric_results,
      metric_weights: weights,
      provisional?: false,
      strongest: strongest.key,
      weakest: weakest.key,
      explanation: explanation(provisional?, strongest, weakest)
    }
  end

  @doc """
  Projects Asset Management for a state-only comparison.

  The four canonical metric slots and their full 35/20/20/25 weights are
  preserved, but Asset Exchange Value is fixed at neutral `50`. This deliberately
  ignores whether the canonical AEV result was provisional: transaction-path
  evidence must affect neither side of a Comparable State Score.
  """
  @spec comparable_score([MetricResult.t()]) :: float()
  def comparable_score(
        [
          %MetricResult{key: :draft_capital_value},
          %MetricResult{key: :pick_quality},
          %MetricResult{key: :asset_optionality},
          %MetricResult{key: :asset_exchange_value}
        ] = metric_results
      ) do
    metric_results
    |> Enum.map(fn
      %MetricResult{key: :asset_exchange_value} = result -> %{result | score: 50.0}
      result -> result
    end)
    |> weighted_score(@full_weights)
  end

  defp weighted_score(results, weights) when is_list(results) do
    Enum.reduce(results, 0.0, fn result, total ->
      total + result.score * Map.fetch!(weights, result.key)
    end)
  end

  defp extrema(results), do: {Enum.max_by(results, & &1.score), Enum.min_by(results, & &1.score)}

  defp explanation(
         true,
         %MetricResult{key: strongest, score: strongest_score},
         %MetricResult{key: weakest, score: weakest_score}
       ) do
    rendered_weights =
      [:draft_capital_value, :pick_quality, :asset_optionality]
      |> Enum.map_join("/", &((Map.fetch!(@provisional_weights, &1) * 100) |> Float.round(2)))

    "Asset Exchange Value is displayed but excluded until PTV is calibrated; " <>
      "the 35/20/20 inventory weights are renormalized to #{rendered_weights}. " <>
      "#{label(strongest)} is strongest at #{strongest_score}, while " <>
      "#{label(weakest)} is weakest at #{weakest_score}."
  end

  defp explanation(
         false,
         %MetricResult{key: strongest, score: strongest_score},
         %MetricResult{key: weakest, score: weakest_score}
       ) do
    "Asset Management applies the full 35/20/20/25 composition; " <>
      "#{label(strongest)} is strongest at #{strongest_score}, while " <>
      "#{label(weakest)} is weakest at #{weakest_score}."
  end

  defp label(:draft_capital_value), do: "Draft Capital Value"
  defp label(:pick_quality), do: "Pick Quality"
  defp label(:asset_optionality), do: "Asset Optionality"
  defp label(:asset_exchange_value), do: "Asset Exchange Value"
end
asset_exchange_value.exElixir

lib/trade_machine/gm_grade_v2/pillars/asset_management/asset_exchange_value.ex

Scores the value exchanged in scenario player/draft-asset transactions.

Player values come from the separate PTV model; pick values come from Phase 7 DAV claims. Signings, waivers, cash, and cap mechanics are intentionally not qualifying Asset Exchange transactions.

Download source ↓

SHA-256 626380a5353bbe7134d240d1650d35c85ff6aabd43fe17f466d2f266f6e9c1ce

defmodule TradeMachine.GMGradeV2.Pillars.AssetManagement.AssetExchangeValue do
  @moduledoc """
  Scores the value exchanged in scenario player/draft-asset transactions.

  Player values come from the separate PTV model; pick values come from Phase 7
  DAV claims. Signings, waivers, cash, and cap mechanics are intentionally not
  qualifying Asset Exchange transactions.
  """

  alias TradeMachine.GMGradeV2.DAV.Valuer
  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.MaterialFinding
  alias TradeMachine.GMGradeV2.PTV
  alias TradeMachine.GMGradeV2.Pillars.AssetManagement.JSONSafe
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.TransactionValueCurve

  @plausibility_explanation "This transaction appears highly favorable based on package-equivalent market value. Because the return falls outside the normal acceptance range, the counterparty acceptance curve reduces its Asset Exchange score."
  @negative_outlier_explanation "This transaction appears highly unfavorable based on estimated market value. It falls outside the normal market range, but its Asset Exchange score is already at the lower end of the scale."

  @typedoc """
  Resolution context for scenario transaction components.

  `player_snapshots` is keyed by player id. An optional
  `player_value_resolver` may return an explicit provisional value when a
  traded player is absent from that frozen section. Pick claims must be
  direction-aware:
  `pick_claims` may be `%{incoming: [...], outgoing: [...]}`, where each list is
  built for the owner on that transaction side. A sequential scenario that
  acquires and later flips the same asset supplies `pick_claim_resolver`, a
  two-arity function receiving the stored entry and `:incoming | :outgoing`.
  Scenario assembly may supply a three-arity resolver whose final argument is
  the zero-based transaction index;
  this is the authoritative seam for transaction-time claims that no one final
  owner inventory can represent. A flat claim list remains supported only when
  the caller deliberately has one complete claim universe for both directions.
  `dav_context` is passed unchanged to `Valuer.dav/2`.
  """
  @type context :: %{
          required(:player_snapshots) => map(),
          optional(:player_value_resolver) => (String.t(), map() | nil -> map()),
          optional(:pick_claims) => [map()] | %{incoming: [map()], outgoing: [map()]},
          optional(:pick_claim_resolver) =>
            (map(), :incoming | :outgoing -> map() | {:unavailable, term()})
            | (map(), :incoming | :outgoing, non_neg_integer() ->
                 map() | {:unavailable, term()}),
          required(:dav_context) => map(),
          optional(:ptv_context) => map()
        }

  @doc "Scores qualifying trades from a scenario modifications document."
  @spec score(map(), context()) :: MetricResult.t()
  def score(modifications, context) when is_map(modifications) and is_map(context) do
    context = freeze_ptv_context(context)

    transactions =
      modifications
      |> Map.get("trades", [])
      |> Enum.with_index()
      |> Enum.map(fn {trade, index} -> resolve_transaction(trade, index, context) end)
      |> Enum.reject(&(&1.components == []))

    document = context.ptv_context.coefficients_document
    build_result(transactions, document)
  end

  @doc "Scores already-resolved transaction components."
  @spec score_transactions([map()]) :: MetricResult.t()
  def score_transactions(transactions) when is_list(transactions),
    do: build_result(transactions, PTV.coefficients_document())

  @doc "Returns the explicit AEV status carried by a metric result."
  @spec status(MetricResult.t()) :: :no_transactions | :provisional | :ok
  def status(%MetricResult{diagnostics: diagnostics}), do: Map.fetch!(diagnostics, :status)

  @doc "Applies the versioned package-equivalence and counterparty-acceptance curves."
  @spec transaction_score([number()], [number()]) :: map()
  def transaction_score(incoming, outgoing) when is_list(incoming) and is_list(outgoing) do
    result = TransactionValueCurve.evaluate(incoming, outgoing)

    Map.merge(result, %{
      plausibility_explanation:
        cond do
          result.plausibility_flagged? and result.net_advantage > 0 ->
            @plausibility_explanation

          result.plausibility_flagged? ->
            @negative_outlier_explanation

          true ->
            nil
        end
    })
  end

  defp resolve_transaction(trade, index, context) do
    incoming =
      resolve_components(
        Map.get(trade, "players_in") || [],
        Map.get(trade, "picks_in") || [],
        :incoming,
        index,
        context
      )

    outgoing =
      resolve_components(
        Map.get(trade, "players_out") || [],
        Map.get(trade, "picks_out") || [],
        :outgoing,
        index,
        context
      )

    score =
      transaction_score(
        Enum.map(incoming, & &1.value),
        Enum.map(outgoing, & &1.value)
      )

    Map.merge(score, %{
      id: Map.get(trade, "id", index),
      components: incoming ++ outgoing,
      player_involved?: Enum.any?(incoming ++ outgoing, &(&1.type == :player))
    })
  end

  defp resolve_components(players, picks, direction, transaction_index, context) do
    player_components =
      Enum.map(players, fn player ->
        player_id = Map.fetch!(player, "player_id")
        result = resolve_player_value(context, player_id)

        %{
          type: :player,
          direction: direction,
          id: player_id,
          value: result.ptv,
          confidence: result.confidence,
          diagnostics: result.diagnostics
        }
      end)

    pick_components =
      Enum.map(picks, fn pick ->
        case resolve_claim!(context, pick, direction, transaction_index) do
          {:unavailable, reason} ->
            identity = stored_claim_identity(pick)

            %{
              type: :draft_asset,
              direction: direction,
              id: JSONSafe.value(identity),
              claim_identity: identity,
              value: 0.0,
              confidence: :provisional,
              diagnostics: %{method: :unavailable_frozen_draft_claim, reason: inspect(reason)}
            }

          claim ->
            result = Valuer.dav(claim, Map.fetch!(context, :dav_context))

            %{
              type: :draft_asset,
              direction: direction,
              id: claim_identity(claim) |> JSONSafe.value(),
              claim_identity: claim_identity(claim),
              value: result.dav,
              diagnostics: JSONSafe.value(result.diagnostics)
            }
        end
      end)

    components = player_components ++ pick_components
    assert_unique_claims!(components, direction)
    Enum.map(components, &Map.delete(&1, :claim_identity))
  end

  defp resolve_player_value(%{player_value_resolver: resolver} = context, player_id)
       when is_function(resolver, 2) do
    snapshots = Map.fetch!(context, :player_snapshots)
    resolver.(player_id, Map.get(snapshots, player_id))
  end

  defp resolve_player_value(context, player_id) do
    snapshot = fetch_player_snapshot!(context, player_id)
    PTV.value(snapshot, Map.get(context, :ptv_context, %{}))
  end

  defp fetch_player_snapshot!(context, player_id) do
    snapshots = Map.fetch!(context, :player_snapshots)

    case Map.fetch(snapshots, player_id) do
      {:ok, snapshot} -> snapshot
      :error -> raise KeyError, key: player_id, term: snapshots
    end
  end

  defp resolve_claim!(%{pick_claim_resolver: resolver}, entry, direction, transaction_index)
       when is_function(resolver, 3) do
    validate_resolved_claim(resolver.(entry, direction, transaction_index), direction)
  end

  defp resolve_claim!(%{pick_claim_resolver: resolver}, entry, direction, _transaction_index)
       when is_function(resolver, 2) do
    validate_resolved_claim(resolver.(entry, direction), direction)
  end

  defp resolve_claim!(context, entry, direction, _transaction_index) do
    context
    |> claims_for_direction!(direction)
    |> find_claim!(entry)
  end

  defp validate_resolved_claim(result, direction) do
    case result do
      %{kind: kind} = claim when kind in [:whole, :range_part, :swap_outcome] ->
        claim

      {:unavailable, _reason} = unavailable ->
        unavailable

      other ->
        raise ArgumentError,
              "pick_claim_resolver returned no valueable DAV claim for #{direction}: " <>
                inspect(other)
    end
  end

  defp claims_for_direction!(context, direction) do
    case Map.fetch(context, :pick_claims) do
      {:ok, claims} when is_list(claims) ->
        claims

      {:ok, %{^direction => claims}} when is_list(claims) ->
        claims

      {:ok, directional} when is_map(directional) ->
        raise ArgumentError, "pick_claims has no #{direction} claim list"

      :error ->
        raise ArgumentError,
              "AEV requires direction-aware pick_claims or pick_claim_resolver for draft assets"
    end
  end

  defp find_claim!(claims, %{"asset_kind" => "swap_outcome"} = entry) do
    group_id = Map.get(entry, "swap_group_id")
    rank = Map.get(entry, "rank")

    if is_nil(group_id) or is_nil(rank) do
      raise ArgumentError,
            "swap-outcome entry must carry swap_group_id and rank: #{inspect(entry)}"
    end

    unique_claim!(
      Enum.filter(
        claims,
        &(&1.kind == :swap_outcome and &1.swap_group_id == group_id and &1.rank == rank)
      ),
      {:swap_outcome, group_id, rank}
    )
  end

  defp find_claim!(claims, entry) do
    part_id = Map.get(entry, "part_id")
    pick_id = Map.get(entry, "pick_id")

    part_matches =
      if is_nil(part_id),
        do: [],
        else: Enum.filter(claims, &(&1.kind != :rollover_fallback and &1.part_id == part_id))

    cond do
      part_matches != [] ->
        unique_claim!(part_matches, {:part, part_id})

      not is_nil(part_id) and is_nil(pick_id) ->
        unique_claim!([], {:part, part_id})

      not is_nil(pick_id) ->
        claims
        |> Enum.filter(&(&1.kind == :whole and &1.pick_id == pick_id))
        |> unique_claim!({:pick, pick_id})

      true ->
        raise ArgumentError,
              "draft-asset entry has no part-level, swap-outcome, or whole-pick identity: " <>
                inspect(
                  Map.take(entry, ["asset_kind", "part_id", "pick_id", "swap_group_id", "rank"])
                )
    end
  end

  defp claim_identity(%{kind: :swap_outcome} = claim),
    do: {:swap_outcome, claim.swap_group_id, claim.rank}

  defp claim_identity(%{part_id: part_id}) when not is_nil(part_id), do: {:part, part_id}
  defp claim_identity(claim), do: {:pick, Map.fetch!(claim, :pick_id)}

  defp stored_claim_identity(%{"asset_kind" => "swap_outcome"} = entry),
    do: {:swap_outcome, entry["swap_group_id"], entry["rank"]}

  defp stored_claim_identity(entry),
    do: {:draft_asset, entry["part_id"], entry["draft_pick_id"] || entry["pick_id"]}

  defp unique_claim!([claim], _identity), do: claim

  defp unique_claim!([], identity) do
    raise ArgumentError, "no DAV claim matches scenario draft-asset identity #{inspect(identity)}"
  end

  defp unique_claim!(claims, identity) do
    raise ArgumentError,
          "scenario draft-asset identity #{inspect(identity)} is ambiguous across " <>
            "#{length(claims)} DAV claims"
  end

  defp assert_unique_claims!(components, direction) do
    identities =
      for %{type: :draft_asset, claim_identity: identity} <- components do
        identity
      end

    duplicate =
      identities
      |> Enum.frequencies()
      |> Enum.find(fn {_identity, count} -> count > 1 end)

    if duplicate do
      {identity, count} = duplicate

      raise ArgumentError,
            "#{direction} transaction side resolves #{count} entries to DAV claim " <>
              "#{inspect(identity)}"
    end
  end

  defp freeze_ptv_context(context) do
    ptv_context =
      context
      |> Map.get(:ptv_context, %{})
      |> Map.put_new(:coefficients_document, PTV.coefficients_document())

    Map.put(context, :ptv_context, ptv_context)
  end

  defp build_result([], _document) do
    %MetricResult{
      key: :asset_exchange_value,
      version: ModelVersion.model_version(),
      score: 50.0,
      inputs: %{transaction_count: 0},
      supporting: %{transactions: []},
      explanation:
        "Asset Exchange Value is neutral because the scenario contains no qualifying transactions.",
      diagnostics: %{status: :no_transactions, plausibility_findings: []}
    }
  end

  defp build_result(transactions, document) do
    total_scale = Enum.reduce(transactions, 0.0, &(&2 + &1.scale))

    weighted_score =
      Enum.reduce(transactions, 0.0, fn transaction, total ->
        total + transaction.score * transaction.scale / total_scale
      end)

    unavailable_component? =
      Enum.any?(transactions, fn transaction ->
        Enum.any?(transaction.components, &(Map.get(&1, :confidence) == :provisional))
      end)

    provisional? =
      unavailable_component? or
        (not Map.fetch!(document, "calibrated") and
           Enum.any?(transactions, & &1.player_involved?)) or
        not TransactionValueCurve.calibrated?()

    status = if(provisional?, do: :provisional, else: :ok)

    findings =
      for transaction <- transactions, transaction.plausibility_flagged? do
        MaterialFinding.new(%{
          transaction_id: transaction.id,
          move_ref: MaterialFinding.move_ref(:trade, transaction.id),
          transaction_type: :trade,
          display_label: "Trade #{transaction.id}",
          teams: [],
          assets: finding_assets(transaction.components),
          severity: finding_severity(transaction),
          reason_category: :asset_value_outlier,
          net_advantage: transaction.net_advantage,
          scale: transaction.scale,
          acceptance_multiplier: transaction.acceptance_multiplier,
          uncapped_score: transaction.uncapped_score,
          applied_score: transaction.score,
          explanation: transaction.plausibility_explanation
        })
      end

    %MetricResult{
      key: :asset_exchange_value,
      version: ModelVersion.model_version(),
      score: weighted_score,
      inputs: %{transaction_count: length(transactions), total_scale: total_scale},
      supporting: %{transactions: transactions},
      fallbacks: provisional_fallbacks(provisional?),
      explanation: explanation(status, length(transactions)),
      diagnostics: %{status: status, plausibility_findings: findings}
    }
  end

  defp finding_severity(%{net_advantage: net}) when is_number(net) and net < 0, do: :warning
  defp finding_severity(_transaction), do: :unlikely

  defp explanation(:provisional, count),
    do:
      "Asset Exchange Value scored #{count} qualifying transactions provisionally because player valuation calibration or frozen component evidence is incomplete."

  defp explanation(:ok, count),
    do: "Asset Exchange Value scale-weights #{count} qualifying transactions."

  defp provisional_fallbacks(false), do: []

  defp provisional_fallbacks(true) do
    [
      %{
        input: :player_transaction_value,
        method: :uncalibrated_valuation_curves,
        note:
          "Player or transaction valuation is uncalibrated; Asset Exchange Value is displayed but excluded from the pillar score."
      }
    ]
  end

  defp finding_assets(components) do
    Enum.map(components, fn component ->
      %{
        id: Map.get(component, :id),
        type: Map.get(component, :type),
        direction: Map.get(component, :direction),
        label: component |> Map.get(:id, "Unknown asset") |> asset_label()
      }
    end)
  end

  defp asset_label(value) when is_binary(value), do: value
  defp asset_label(value) when is_atom(value) or is_number(value), do: to_string(value)
  defp asset_label(value), do: inspect(value)
end
asset_optionality.exElixir

lib/trade_machine/gm_grade_v2/pillars/asset_management/asset_optionality.ex

Scores draft-asset flexibility from Phase 7 PickCapacity facts.

The Stepien search and part-level encumbrance normalization stay in PickCapacity ; this module owns only AVC's three scoring curves and penalty weights.

Download source ↓

SHA-256 b634362ae62059b767989615fc4b3ee6d5c808f15b6b2d40bbdd56a937835c98

defmodule TradeMachine.GMGradeV2.Pillars.AssetManagement.AssetOptionality do
  @moduledoc """
  Scores draft-asset flexibility from Phase 7 `PickCapacity` facts.

  The Stepien search and part-level encumbrance normalization stay in
  `PickCapacity`; this module owns only AVC's three scoring curves and penalty
  weights.
  """

  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.PickCapacity
  alias TradeMachine.GMGradeV2.Pillars.AssetManagement.JSONSafe
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @weights %{
    tradable_first_capacity: 0.50,
    clean_first_round_years: 0.30,
    encumbrance_control: 0.20
  }
  @capacity_curve [{0, 0.0}, {1, 35.0}, {2, 60.0}, {3, 78.0}, {4, 90.0}, {5, 100.0}]
  @clean_year_curve [
    {0, 0.0},
    {1, 15.0},
    {2, 30.0},
    {3, 50.0},
    {4, 68.0},
    {5, 82.0},
    {6, 92.0},
    {7, 100.0}
  ]

  if abs(Enum.sum(Map.values(@weights)) - 1.0) > 1.0e-9 do
    raise "Asset Optionality weights must sum to 1.0"
  end

  @doc "Builds Phase 7 capacity facts and scores them."
  @spec score(binary(), PickCapacity.graph(), Date.t()) :: MetricResult.t()
  def score(team_id, graph, %Date{} = as_of_date) do
    team_id
    |> PickCapacity.analyze(graph, as_of_date)
    |> score_analysis()
  end

  @doc "Scores an already-built `PickCapacity.analyze/3` result."
  @spec score_analysis(map()) :: MetricResult.t()
  def score_analysis(%{
        capacity: capacity,
        clean_years: clean_years,
        encumbrances: encumbrances
      }) do
    capacity_score = tradable_capacity_score(capacity.capacity)
    clean_score = clean_years_score(clean_years.count)
    encumbrance_score = encumbrance_control(encumbrances)

    score =
      capacity_score * @weights.tradable_first_capacity +
        clean_score * @weights.clean_first_round_years +
        encumbrance_score * @weights.encumbrance_control

    %MetricResult{
      key: :asset_optionality,
      version: ModelVersion.model_version(),
      score: score,
      inputs: %{
        tradable_first_capacity: capacity.capacity,
        clean_first_round_years: clean_years.count,
        encumbrances: JSONSafe.value(encumbrances)
      },
      supporting: %{
        tradable_first_capacity: capacity_score,
        calendar_flexibility: clean_score,
        encumbrance_control: encumbrance_score,
        capacity: JSONSafe.value(capacity),
        clean_years: JSONSafe.value(clean_years)
      },
      weights: @weights,
      explanation:
        "Asset Optionality combines #{capacity.capacity} immediately tradable firsts, " <>
          "#{clean_years.count} clean first-round years, and normalized encumbrance control."
    }
  end

  @doc "Applies AVC's immediately-tradable-firsts curve."
  @spec tradable_capacity_score(number()) :: float()
  def tradable_capacity_score(value) when is_number(value) and value >= 0,
    do: interpolate(value, @capacity_curve)

  @doc "Applies AVC's seven-year calendar-flexibility curve."
  @spec clean_years_score(number()) :: float()
  def clean_years_score(value) when is_number(value) and value >= 0,
    do: interpolate(value, @clean_year_curve)

  @doc "Applies AVC's penalties to normalized encumbrance facts."
  @spec encumbrance_control(map()) :: float()
  def encumbrance_control(facts) do
    penalties =
      15 * length(Map.fetch!(facts, :owed_first_years)) +
        10 * length(Map.fetch!(facts, :rollover_risk_years)) +
        7 * Map.fetch!(facts, :owed_swaps) +
        3 * length(Map.fetch!(facts, :owed_second_years)) +
        if(Map.fetch!(facts, :complex_chain), do: 10, else: 0)

    max(0.0, 100.0 - penalties)
  end

  defp interpolate(value, [{minimum, score} | _rest]) when value <= minimum, do: score

  defp interpolate(value, points) do
    case Enum.chunk_every(points, 2, 1, :discard)
         |> Enum.find(fn [{lower, _}, {upper, _}] -> value >= lower and value <= upper end) do
      [{lower, lower_score}, {upper, upper_score}] ->
        lower_score + (upper_score - lower_score) * (value - lower) / (upper - lower)

      nil ->
        points |> List.last() |> elem(1)
    end
  end
end
draft_capital_value.exElixir

lib/trade_machine/gm_grade_v2/pillars/asset_management/draft_capital_value.ex

Scores the post-scenario draft inventory against the frozen 30-team DAV total.

DAV.Inventory is the shared Phase 7 implementation of AVC's controlled-claim rules: it resolves part-level ownership, excludes out-of-window and duplicate claims, values swaps as inventory holdings, and preserves incremental swap DAV separately for the expanded view.

Download source ↓

SHA-256 0d20c6d5125cc386330845ca1b38bd567e535b512dd2a93a6023c08b136a9fea

defmodule TradeMachine.GMGradeV2.Pillars.AssetManagement.DraftCapitalValue do
  @moduledoc """
  Scores the post-scenario draft inventory against the frozen 30-team DAV total.

  `DAV.Inventory` is the shared Phase 7 implementation of AVC's controlled-claim
  rules: it resolves part-level ownership, excludes out-of-window and duplicate
  claims, values swaps as inventory holdings, and preserves incremental swap DAV
  separately for the expanded view.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.DAV.Inventory
  alias TradeMachine.GMGradeV2.Percentile
  alias TradeMachine.GMGradeV2.Pillars.AssetManagement.JSONSafe
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @doc "Builds and scores one team's final seven-draft inventory."
  @spec score(binary(), Inventory.graph(), map(), Benchmark.t()) :: MetricResult.t()
  def score(team_id, graph, dav_context, %Benchmark{} = benchmark) do
    team_id
    |> Inventory.value(graph, dav_context)
    |> score_inventory(benchmark)
  end

  @doc "Scores an already-built Phase 7 inventory against the frozen benchmark."
  @spec score_inventory(Inventory.t(), Benchmark.t()) :: MetricResult.t()
  def score_inventory(inventory, %Benchmark{} = benchmark) do
    benchmark_values = benchmark_values(benchmark, "total")

    %MetricResult{
      key: :draft_capital_value,
      version: benchmark.version,
      score: Percentile.score(inventory.total, benchmark_values),
      inputs: %{
        total_dav: inventory.total,
        asset_count: inventory.asset_count,
        window: %{first_year: inventory.window.first, last_year: inventory.window.last},
        benchmark: benchmark_values
      },
      supporting: %{
        first_round_dav: inventory.first_round,
        second_round_dav: inventory.second_round,
        swap_dav: inventory.swap_gain,
        by_year: breakdown(inventory.assets, & &1.year),
        by_origin_team: breakdown(inventory.assets, &(&1.original_team_id || :swap_pool)),
        assets: JSONSafe.value(inventory.assets)
      },
      explanation:
        "Draft Capital Value percentiles #{inventory.total} total DAV across " <>
          "#{inventory.asset_count} controlled assets against the frozen league inventory."
    }
  end

  defp benchmark_values(benchmark, metric) do
    benchmark
    |> Benchmark.section(:dav)
    |> Map.fetch!("metrics")
    |> Map.fetch!(metric)
  end

  defp breakdown(assets, key_fun) do
    assets
    |> Enum.group_by(key_fun)
    |> Enum.map(fn {key, grouped} ->
      {key, Enum.reduce(grouped, 0.0, &(&2 + &1.inventory_dav))}
    end)
    |> Map.new()
  end
end
json_safe.exElixir

lib/trade_machine/gm_grade_v2/pillars/asset_management/json_safe.ex

See the implementation below.

Download source ↓

SHA-256 873d8bcac054f5b87bbb983c9b09e650a17f9f27371c2008fd32c7c3b622fd11

defmodule TradeMachine.GMGradeV2.Pillars.AssetManagement.JSONSafe do
  @moduledoc false

  @doc "Recursively converts scoring diagnostics to values accepted by Jason/jsonb."
  def value(%Range{} = range), do: %{first: range.first, last: range.last, step: range.step}
  def value(%Date{} = date), do: Date.to_iso8601(date)
  def value(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime)
  def value(%NaiveDateTime{} = datetime), do: NaiveDateTime.to_iso8601(datetime)
  def value(tuple) when is_tuple(tuple), do: tuple |> Tuple.to_list() |> Enum.map(&value/1)
  def value(list) when is_list(list), do: Enum.map(list, &value/1)

  def value(%_{} = struct) do
    struct
    |> Map.from_struct()
    |> Map.drop([:__meta__])
    |> value()
  end

  def value(map) when is_map(map), do: Map.new(map, fn {key, item} -> {key, value(item)} end)
  def value(other), do: other
end
pick_quality.exElixir

lib/trade_machine/gm_grade_v2/pillars/asset_management/pick_quality.ex

Scores the strength and depth of a team's best controlled draft assets.

Best Asset and Top-Three raws come from the same DAV.Inventory used by the frozen benchmark. Premium Asset Inventory is AVC's threshold-and-curve score.

Download source ↓

SHA-256 c51aef5d3c9949bd7056f0933442d25ef686eec8d2b507227935563115977061

defmodule TradeMachine.GMGradeV2.Pillars.AssetManagement.PickQuality do
  @moduledoc """
  Scores the strength and depth of a team's best controlled draft assets.

  Best Asset and Top-Three raws come from the same `DAV.Inventory` used by the
  frozen benchmark. Premium Asset Inventory is AVC's threshold-and-curve score.
  """

  alias TradeMachine.GMGradeV2.Benchmark
  alias TradeMachine.GMGradeV2.DAV.Inventory
  alias TradeMachine.GMGradeV2.Percentile
  alias TradeMachine.GMGradeV2.Pillars.AssetManagement.JSONSafe
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @weights %{best_asset: 0.40, top_three: 0.40, premium_inventory: 0.20}
  @premium_curve [{0.0, 0.0}, {0.5, 30.0}, {1.0, 50.0}, {2.0, 75.0}, {3.0, 90.0}, {4.0, 100.0}]

  if abs(Enum.sum(Map.values(@weights)) - 1.0) > 1.0e-9 do
    raise "Pick Quality weights must sum to 1.0"
  end

  @doc "Builds and scores one team's final seven-draft inventory."
  @spec score(binary(), Inventory.graph(), map(), Benchmark.t()) :: MetricResult.t()
  def score(team_id, graph, dav_context, %Benchmark{} = benchmark) do
    team_id
    |> Inventory.value(graph, dav_context)
    |> score_inventory(benchmark)
  end

  @doc "Scores an already-built Phase 7 inventory against the frozen benchmark."
  @spec score_inventory(Inventory.t(), Benchmark.t()) :: MetricResult.t()
  def score_inventory(inventory, %Benchmark{} = benchmark) do
    best_quality =
      Percentile.score(inventory.best_asset, benchmark_values(benchmark, "best_asset"))

    top_three_quality =
      Percentile.score(inventory.top_three, benchmark_values(benchmark, "top_three"))

    premium_units = premium_units(inventory.assets)
    premium_score = premium_inventory_score(premium_units)

    score =
      best_quality * @weights.best_asset +
        top_three_quality * @weights.top_three +
        premium_score * @weights.premium_inventory

    ranked = Enum.sort_by(inventory.assets, &rank_key/1)

    %MetricResult{
      key: :pick_quality,
      version: benchmark.version,
      score: score,
      inputs: %{
        best_asset_raw: inventory.best_asset,
        top_three_raw: inventory.top_three,
        premium_asset_units: premium_units
      },
      supporting: %{
        best_asset_quality: best_quality,
        top_three_quality: top_three_quality,
        premium_asset_inventory: premium_score,
        best_asset: ranked |> List.first() |> JSONSafe.value(),
        top_three_assets: ranked |> Enum.take(3) |> JSONSafe.value()
      },
      weights: @weights,
      explanation:
        "Pick Quality combines frozen-league percentiles for the best asset and " <>
          "top three with #{premium_units} Premium Asset Units."
    }
  end

  @doc "Returns AVC Premium Asset Units for a list of valued inventory assets."
  @spec premium_units([map()]) :: float()
  def premium_units(assets) when is_list(assets) do
    Enum.reduce(assets, 0.0, fn asset, total ->
      total + premium_units_for(Map.fetch!(asset, :inventory_dav))
    end)
  end

  @doc "Maps one individual DAV value to AVC Premium Asset Units."
  @spec premium_units_for(number()) :: float()
  def premium_units_for(dav) when is_number(dav) and dav >= 75, do: 1.0
  def premium_units_for(dav) when is_number(dav) and dav >= 55, do: 0.75
  def premium_units_for(dav) when is_number(dav) and dav >= 35, do: 0.40
  def premium_units_for(dav) when is_number(dav), do: 0.0

  @doc "Applies AVC's piecewise-linear Premium Asset Inventory curve."
  @spec premium_inventory_score(number()) :: float()
  def premium_inventory_score(units) when is_number(units) and units >= 0 do
    interpolate(units, @premium_curve)
  end

  defp benchmark_values(benchmark, metric) do
    benchmark
    |> Benchmark.section(:dav)
    |> Map.fetch!("metrics")
    |> Map.fetch!(metric)
  end

  defp rank_key(asset) do
    {-asset.inventory_dav, asset.year, asset.round, asset.pick_id || "",
     asset.swap_group_id || "", asset.rank || 0}
  end

  defp interpolate(value, [{minimum, score} | _rest]) when value <= minimum, do: score

  defp interpolate(value, points) do
    case Enum.chunk_every(points, 2, 1, :discard)
         |> Enum.find(fn [{lower, _}, {upper, _}] -> value >= lower and value <= upper end) do
      [{lower, lower_score}, {upper, upper_score}] ->
        lower_score + (upper_score - lower_score) * (value - lower) / (upper - lower)

      nil ->
        points |> List.last() |> elem(1)
    end
  end
end

Found an unexpected score? Record the model version, mandate, evidence date, and the smallest scenario that reproduces it.

Next: Timeline alignment →

Teams

More

Current challenges How challenges are graded Explore the six pillars of a good GM
Browse
Team Contracts Player Contracts FA Market
App
Install app

Open trades and cap sheets straight from your home screen in their own app window.

iPhone or iPad

In Safari, open the Share menu, choose Add to Home Screen, then tap Add. Keep Open as Web App enabled if shown.

Android or desktop

Use Install Armchair GM above when available, or open your browser menu and choose Install app or Add to Home screen.

An internet connection is required for live data, trades, and scenario changes.

What's new About
Account
Sign in Tip jar Support Armchair GM