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

Documentation The six pillars

Cap & flexibility

Current cap position, future flexibility, tax/apron exposure, and transaction flexibility use the cap engine's financial state and scoring curves.

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
Current cap position 20.0%
Tax apron exposure 30.0%
Future flexibility 30.0%
Transaction flexibility 20.0%

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.

cap_flexibility.exElixir

lib/trade_machine/gm_grade_v2/pillars/cap_flexibility.ex

Aggregates AVC's four Cap & Flexibility metrics from a CapState snapshot.

Current Cap Position, Tax & Apron Exposure, Future Flexibility, and Transaction Flexibility retain full precision through the fixed 20/30/30/20 weighted sum.

Download source ↓

SHA-256 6bf1f63648b73e4994ca2ed491fe63f40c9436e1a364d42f99c5b3bd2c1f114d

defmodule TradeMachine.GMGradeV2.Pillars.CapFlexibility do
  @moduledoc """
  Aggregates AVC's four Cap & Flexibility metrics from a `CapState` snapshot.

  Current Cap Position, Tax & Apron Exposure, Future Flexibility, and
  Transaction Flexibility retain full precision through the fixed 20/30/30/20
  weighted sum.
  """

  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Pillars.CapFlexibility.CurrentCapPosition
  alias TradeMachine.GMGradeV2.Pillars.CapFlexibility.FutureFlexibility
  alias TradeMachine.GMGradeV2.Pillars.CapFlexibility.TaxApronExposure
  alias TradeMachine.GMGradeV2.Pillars.CapFlexibility.TransactionFlexibility
  alias TradeMachine.GMGradeV2.Result.MetricResult
  alias TradeMachine.GMGradeV2.Result.PillarResult

  @metric_weights %{
    current_cap_position: 0.20,
    tax_apron_exposure: 0.30,
    future_flexibility: 0.30,
    transaction_flexibility: 0.20
  }

  @metric_modules [
    CurrentCapPosition,
    TaxApronExposure,
    FutureFlexibility,
    TransactionFlexibility
  ]

  if abs(Enum.sum(Map.values(@metric_weights)) - 1.0) > 1.0e-9 do
    raise "Cap & Flexibility metric weights must sum to 1.0"
  end

  @doc "Returns the full-precision Cap & Flexibility pillar result."
  @spec score(map()) :: PillarResult.t()
  def score(cap_state) when is_map(cap_state) do
    metric_results = Enum.map(@metric_modules, & &1.score(cap_state))
    {strongest, weakest} = extrema(metric_results)

    %PillarResult{
      key: :cap_flexibility,
      version: ModelVersion.model_version(),
      score: weighted_score(metric_results),
      metric_results: metric_results,
      metric_weights: @metric_weights,
      explanation: explanation(strongest, weakest),
      strongest: strongest.key,
      weakest: weakest.key
    }
  end

  @doc "Combines Cap & Flexibility metric results at AVC's fixed weights."
  @spec weighted_score([MetricResult.t()]) :: float()
  def weighted_score(metric_results) when is_list(metric_results) do
    # Intentional AVC emphasis: CapState's Effective Committed Salary already
    # includes dead money, and Future Flexibility also applies the separate Dead
    # Money Score. Do not remove this deliberate double-count.
    Enum.reduce(metric_results, 0.0, fn result, total ->
      total + result.score * Map.fetch!(@metric_weights, result.key)
    end)
  end

  defp extrema(metric_results) do
    {
      Enum.max_by(metric_results, & &1.score),
      Enum.min_by(metric_results, & &1.score)
    }
  end

  defp explanation(
         %MetricResult{key: strongest_key, score: strongest_score},
         %MetricResult{key: weakest_key, score: weakest_score}
       ) do
    "Cap & Flexibility combines four cap-state metrics; " <>
      "#{label(strongest_key)} is strongest at #{strongest_score}, while " <>
      "#{label(weakest_key)} is weakest at #{weakest_score}."
  end

  defp label(:current_cap_position), do: "Current Cap Position"
  defp label(:tax_apron_exposure), do: "Tax & Apron Exposure"
  defp label(:future_flexibility), do: "Future Flexibility"
  defp label(:transaction_flexibility), do: "Transaction Flexibility"
end
current_cap_position.exElixir

lib/trade_machine/gm_grade_v2/pillars/cap_flexibility/current_cap_position.ex

Scores AVC's Current Cap Position metric from a validated CapState snapshot.

The financial zone and dollar cushions are engine-owned facts. This module only normalizes the applicable cushion by the season's salary cap and applies AVC's five scoring curves, including their intentional threshold cliffs.

Download source ↓

SHA-256 86523b0ebfc798130d4b957dad593e8019cf1c39fc8ad1f9e2f21102e1d472b8

defmodule TradeMachine.GMGradeV2.Pillars.CapFlexibility.CurrentCapPosition do
  @moduledoc """
  Scores AVC's Current Cap Position metric from a validated `CapState` snapshot.

  The financial zone and dollar cushions are engine-owned facts. This module
  only normalizes the applicable cushion by the season's salary cap and applies
  AVC's five scoring curves, including their intentional threshold cliffs.
  """

  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @doc "Returns the full-precision Current Cap Position metric result."
  @spec score(map()) :: MetricResult.t()
  def score(%{
        season: season,
        financial_zone: zone,
        thresholds: thresholds,
        cushions: cushions
      }) do
    salary_cap = thresholds |> Map.fetch!(season) |> Map.fetch!(:salary_cap)
    score = zone_score(zone, cushions, salary_cap)

    %MetricResult{
      key: :current_cap_position,
      version: ModelVersion.model_version(),
      score: score,
      inputs: %{salary_cap: salary_cap},
      supporting: Map.put(cushions, :financial_zone, zone),
      explanation:
        "Current Cap Position scores the #{label(zone)} zone using its engine-calculated " <>
          "threshold cushion relative to the season's salary cap."
    }
  end

  @doc "Applies AVC's financial-zone curve to engine-calculated cushions."
  @spec zone_score(atom(), map(), pos_integer()) :: float()
  def zone_score(:below_cap, cushions, salary_cap) do
    60.0 + min(40.0, ratio(cushions.cap_room, salary_cap) * 200.0)
  end

  def zone_score(:over_cap_below_tax, cushions, salary_cap) do
    50.0 + min(10.0, ratio(cushions.tax_cushion, salary_cap) * 100.0)
  end

  def zone_score(:taxpayer_below_first_apron, cushions, salary_cap) do
    30.0 + min(20.0, ratio(cushions.first_apron_cushion, salary_cap) * 200.0)
  end

  def zone_score(:between_aprons, cushions, salary_cap) do
    10.0 + min(20.0, ratio(cushions.second_apron_cushion, salary_cap) * 200.0)
  end

  def zone_score(:above_second_apron, cushions, salary_cap) do
    max(0.0, 10.0 - ratio(cushions.second_apron_excess, salary_cap) * 200.0)
  end

  defp ratio(amount, salary_cap)
       when is_number(amount) and amount >= 0 and is_number(salary_cap) and salary_cap > 0,
       do: amount / salary_cap

  defp label(:below_cap), do: "below-cap"
  defp label(:over_cap_below_tax), do: "over-cap, below-tax"
  defp label(:taxpayer_below_first_apron), do: "taxpayer, below-first-apron"
  defp label(:between_aprons), do: "between-aprons"
  defp label(:above_second_apron), do: "above-second-apron"
end
future_flexibility.exElixir

lib/trade_machine/gm_grade_v2/pillars/cap_flexibility/future_flexibility.ex

Scores AVC's three-season Future Flexibility metric from CapState .

Effective committed salary, dead money, and projected thresholds are consumed exactly as the cap snapshot reports them. Each future season combines the commitment and dead-money curves at 80/20, then the three seasons combine at 50/30/20.

Download source ↓

SHA-256 d152c3ced49c9afcc6cc4b3c417a4fb6e7c28bc852fc0c83b3c85cb99910a425

defmodule TradeMachine.GMGradeV2.Pillars.CapFlexibility.FutureFlexibility do
  @moduledoc """
  Scores AVC's three-season Future Flexibility metric from `CapState`.

  Effective committed salary, dead money, and projected thresholds are consumed
  exactly as the cap snapshot reports them. Each future season combines the
  commitment and dead-money curves at 80/20, then the three seasons combine at
  50/30/20.
  """

  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @commitment_curve [
    {0.50, 100.0},
    {0.60, 90.0},
    {0.70, 80.0},
    {0.80, 70.0},
    {0.90, 60.0},
    {1.00, 50.0},
    {1.10, 35.0},
    {1.20, 20.0},
    {1.30, 0.0}
  ]

  @dead_money_curve [
    {0.00, 100.0},
    {0.01, 90.0},
    {0.03, 75.0},
    {0.05, 55.0},
    {0.08, 30.0},
    {0.10, 0.0}
  ]

  @component_weights %{commitment: 0.80, dead_money: 0.20}
  @season_weights [0.50, 0.30, 0.20]

  if abs(Enum.sum(Map.values(@component_weights)) - 1.0) > 1.0e-9 or
       abs(Enum.sum(@season_weights) - 1.0) > 1.0e-9 do
    raise "Future Flexibility weights must sum to 1.0"
  end

  @doc "Returns the full-precision Future Flexibility metric result."
  @spec score(map()) :: MetricResult.t()
  def score(%{future_seasons: future_seasons}) do
    seasons =
      case future_seasons do
        [first, second, third] ->
          [first, second, third]

        other ->
          raise ArgumentError,
                "Future Flexibility requires exactly 3 seasons, got: #{length(other)}"
      end

    supporting = Enum.map(seasons, &evaluate_season/1)
    season_scores = Enum.map(supporting, & &1.score)

    %MetricResult{
      key: :future_flexibility,
      version: ModelVersion.model_version(),
      score: combine_seasons(season_scores),
      inputs: %{future_season_count: length(seasons)},
      supporting: %{seasons: supporting},
      fallbacks: estimated_cap_fallbacks(supporting),
      weights: %{
        commitment: @component_weights.commitment,
        dead_money: @component_weights.dead_money,
        future_seasons: @season_weights
      },
      explanation:
        "Future Flexibility scores unavoidable commitments and dead money over the " <>
          "next three cap-state seasons, weighted 50/30/20."
    }
  end

  @doc "Applies AVC's piecewise-linear commitment-ratio curve."
  @spec commitment_score(number()) :: float()
  def commitment_score(ratio) when is_number(ratio) and ratio >= 0 do
    interpolate(ratio, @commitment_curve)
  end

  @doc "Applies AVC's piecewise-linear dead-money-ratio curve."
  @spec dead_money_score(number()) :: float()
  def dead_money_score(ratio) when is_number(ratio) and ratio >= 0 do
    interpolate(ratio, @dead_money_curve)
  end

  @doc "Combines one future season's commitment and dead-money scores."
  @spec season_score(number(), number()) :: float()
  def season_score(commitment, dead_money)
      when is_number(commitment) and is_number(dead_money) do
    commitment * @component_weights.commitment +
      dead_money * @component_weights.dead_money
  end

  @doc "Combines the next three season scores at AVC's 50/30/20 weights."
  @spec combine_seasons([number()]) :: float()
  def combine_seasons([_first, _second, _third] = scores) do
    @season_weights
    |> Enum.zip(scores)
    |> Enum.reduce(0.0, fn {weight, score}, total -> total + weight * score end)
  end

  defp evaluate_season(%{
         season: season,
         thresholds: thresholds,
         effective_committed_salary: committed,
         dead_money: dead_money
       }) do
    salary_cap = Map.fetch!(thresholds, :salary_cap)
    commitment_ratio = committed / salary_cap
    dead_money_ratio = dead_money / salary_cap
    commitment = commitment_score(commitment_ratio)
    dead_money_component = dead_money_score(dead_money_ratio)

    %{
      season: season,
      salary_cap: salary_cap,
      threshold_source: Map.get(thresholds, :source),
      effective_committed_salary: committed,
      commitment_ratio: commitment_ratio,
      commitment_score: commitment,
      dead_money: dead_money,
      dead_money_ratio: dead_money_ratio,
      dead_money_score: dead_money_component,
      score: season_score(commitment, dead_money_component)
    }
  end

  defp estimated_cap_fallbacks(seasons) do
    for season <- seasons, season.threshold_source == :estimated do
      %{
        input: season.season,
        method: :snapshot_escalated_salary_cap,
        note:
          "No official threshold row was available; Future Flexibility used the frozen cap snapshot's recorded escalation fallback."
      }
    end
  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 <= upper and value >= lower 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
tax_apron_exposure.exElixir

lib/trade_machine/gm_grade_v2/pillars/cap_flexibility/tax_apron_exposure.ex

Scores AVC's Tax & Apron Exposure metric from a validated CapState .

CapState owns the tax bill, repeater provenance, financial zone, and signed threshold distances. This metric only normalizes the projected bill by the salary cap and applies the curve for the already-classified zone.

Download source ↓

SHA-256 8326e562ad5dbc139dd874d11398167e6f84e4555e6c59e78834c62b8ebb15ff

defmodule TradeMachine.GMGradeV2.Pillars.CapFlexibility.TaxApronExposure do
  @moduledoc """
  Scores AVC's Tax & Apron Exposure metric from a validated `CapState`.

  `CapState` owns the tax bill, repeater provenance, financial zone, and signed
  threshold distances. This metric only normalizes the projected bill by the
  salary cap and applies the curve for the already-classified zone.
  """

  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @doc "Returns the full-precision Tax & Apron Exposure metric result."
  @spec score(map()) :: MetricResult.t()
  def score(%{
        season: season,
        financial_zone: zone,
        taxpayer: taxpayer?,
        thresholds: thresholds,
        cushions: cushions,
        luxury_tax: luxury_tax
      }) do
    salary_cap = thresholds |> Map.fetch!(season) |> Map.fetch!(:salary_cap)
    tax_bill_ratio = luxury_tax.bill / salary_cap

    %MetricResult{
      key: :tax_apron_exposure,
      version: ModelVersion.model_version(),
      score: exposure_score(zone, tax_bill_ratio),
      inputs: %{
        financial_zone: zone,
        salary_cap: salary_cap,
        projected_tax_bill: luxury_tax.bill,
        tax_bill_ratio: tax_bill_ratio
      },
      supporting: %{
        taxpayer: taxpayer?,
        repeater: luxury_tax.repeater,
        tax_cushion: cushions.tax_cushion,
        first_apron_cushion: cushions.first_apron_cushion,
        second_apron_cushion: cushions.second_apron_cushion
      },
      explanation:
        "Tax & Apron Exposure scores the #{label(zone)} zone using the projected " <>
          "engine tax bill and recorded repeater assumption."
    }
  end

  @doc "Applies AVC's zone-specific tax-bill penalty."
  @spec exposure_score(atom(), number()) :: float()
  def exposure_score(zone, _tax_bill_ratio)
      when zone in [:below_cap, :over_cap_below_tax],
      do: 100.0

  def exposure_score(:taxpayer_below_first_apron, tax_bill_ratio) do
    80.0 - min(20.0, tax_bill_ratio * 50.0)
  end

  def exposure_score(:between_aprons, tax_bill_ratio) do
    45.0 - min(15.0, tax_bill_ratio * 37.5)
  end

  def exposure_score(:above_second_apron, tax_bill_ratio) do
    15.0 - min(15.0, tax_bill_ratio * 37.5)
  end

  defp label(:below_cap), do: "below-cap"
  defp label(:over_cap_below_tax), do: "over-cap, below-tax"
  defp label(:taxpayer_below_first_apron), do: "taxpayer, below-first-apron"
  defp label(:between_aprons), do: "between-aprons"
  defp label(:above_second_apron), do: "above-second-apron"
end
transaction_flexibility.exElixir

lib/trade_machine/gm_grade_v2/pillars/cap_flexibility/transaction_flexibility.ex

Scores AVC's four supporting Transaction Flexibility calculations.

Signing mechanisms, capability flags, usable trade exceptions, roster space, and waivability are consumed from CapState ; no CBA rule is re-derived here. The one time-based calculation discounts a TPE's recorded remaining amount against the snapshot's frozen as_of_date .

CapState.signing_mechanisms.best is deliberately authoritative even where 05-D58-APRON-MLE records that the engine overstates apron-team MLE access. Narrowing it here would violate D-55 by restating the engine's CBA rule.

Download source ↓

SHA-256 1619b301e4988064367fbdaffdd41cab696152d48e41ebf7592106a5a813a4b6

defmodule TradeMachine.GMGradeV2.Pillars.CapFlexibility.TransactionFlexibility do
  @moduledoc """
  Scores AVC's four supporting Transaction Flexibility calculations.

  Signing mechanisms, capability flags, usable trade exceptions, roster space,
  and waivability are consumed from `CapState`; no CBA rule is re-derived here.
  The one time-based calculation discounts a TPE's recorded remaining amount
  against the snapshot's frozen `as_of_date`.

  `CapState.signing_mechanisms.best` is deliberately authoritative even where
  `05-D58-APRON-MLE` records that the engine overstates apron-team MLE access.
  Narrowing it here would violate D-55 by restating the engine's CBA rule.
  """

  alias TradeMachine.GMGradeV2.ModelVersion
  alias TradeMachine.GMGradeV2.Result.MetricResult

  @component_weights %{
    signing_tools: 0.30,
    trade_tools: 0.40,
    trade_exception_capacity: 0.20,
    roster_maneuverability: 0.10
  }

  @trade_penalties %{
    can_aggregate: 25.0,
    can_take_back_more: 20.0,
    can_receive_sign_and_trade: 15.0,
    can_use_existing_tpe: 15.0,
    can_send_cash: 5.0
  }

  @doc "Returns the full-precision Transaction Flexibility metric result."
  @spec score(map()) :: MetricResult.t()
  def score(%{
        season: season,
        as_of_date: %Date{} = as_of_date,
        thresholds: thresholds,
        signing_mechanisms: signing_mechanisms,
        capabilities: capabilities,
        trade_exceptions: trade_exceptions,
        roster: roster
      }) do
    ntmle = thresholds |> Map.fetch!(season) |> Map.fetch!(:non_taxpayer_mle)

    components = %{
      signing_tools: signing_tools(signing_mechanisms, ntmle),
      trade_tools: trade_tools(capabilities),
      trade_exception_capacity: trade_exception_capacity(trade_exceptions, ntmle, as_of_date),
      roster_maneuverability: roster_maneuverability(roster)
    }

    %MetricResult{
      key: :transaction_flexibility,
      version: ModelVersion.model_version(),
      score: combine_components(components),
      inputs: %{
        as_of_date: as_of_date,
        non_taxpayer_mle: ntmle,
        largest_usable_signing_mechanism: signing_mechanisms.best
      },
      supporting: components,
      weights: @component_weights,
      explanation:
        "Transaction Flexibility combines the cap state's usable signing, trade, " <>
          "trade-exception, and roster pathways at AVC's 30/40/20/10 weights."
    }
  end

  @doc "Scores the largest usable CapState signing mechanism against the NTMLE."
  @spec signing_tools(map(), pos_integer()) :: float()
  def signing_tools(%{best: nil}, _ntmle), do: 0.0
  def signing_tools(%{best: %{type: :minimum}}, _ntmle), do: 20.0

  def signing_tools(%{best: %{remaining: remaining}}, ntmle)
      when is_number(remaining) and remaining > 0 and is_number(ntmle) and ntmle > 0 do
    min(100.0, 20.0 + 80.0 * remaining / ntmle)
  end

  @doc "Scores the five Capabilities flags using AVC's fixed penalties."
  @spec trade_tools(map()) :: float()
  def trade_tools(capabilities) when is_map(capabilities) do
    penalty =
      Enum.reduce(@trade_penalties, 0.0, fn {capability, points}, total ->
        if Map.fetch!(capabilities, capability), do: total, else: total + points
      end)

    max(0.0, 100.0 - penalty)
  end

  @doc """
  Scores the largest and second-largest practical TPEs after expiration discounts.

  A TPE must be marked usable by CapState and carry at least 5% of the NTMLE in
  remaining nominal value before its expiration multiplier is applied.
  """
  @spec trade_exception_capacity([map()], pos_integer(), Date.t()) :: float()
  def trade_exception_capacity(trade_exceptions, ntmle, %Date{} = as_of_date)
      when is_list(trade_exceptions) and is_number(ntmle) and ntmle > 0 do
    # Deliberate overlap with Trade Tools: CapState's `:usable` already folds in
    # `can_use_existing_tpe`, so a barred team loses both the general trade
    # capability and the practical capacity of TPEs it cannot consume. These
    # are separate AVC supporting calculations, not duplicate arithmetic.
    [largest, second | _rest] =
      trade_exceptions
      |> Enum.filter(fn tpe ->
        Map.fetch!(tpe, :usable) and Map.fetch!(tpe, :remaining_amount) >= ntmle * 0.05
      end)
      |> Enum.map(fn tpe ->
        days = Date.diff(Map.fetch!(tpe, :expiration_date), as_of_date)
        Map.fetch!(tpe, :remaining_amount) * expiration_multiplier(days)
      end)
      |> Enum.sort(:desc)
      |> Kernel.++([0.0, 0.0])

    100.0 * min(1.0, (largest + 0.25 * second) / ntmle)
  end

  @doc "Returns AVC's expiration multiplier for a TPE's remaining whole days."
  @spec expiration_multiplier(integer()) :: float()
  def expiration_multiplier(days) when days < 30, do: 0.40
  def expiration_multiplier(days) when days <= 90, do: 0.65
  def expiration_multiplier(days) when days <= 180, do: 0.85
  def expiration_multiplier(_days), do: 1.00

  @doc "Combines open standard spots and easily-waivable contracts at 70/30."
  @spec roster_maneuverability(map()) :: float()
  def roster_maneuverability(%{
        open_standard_spots: open_spots,
        easily_waivable: easily_waivable
      }) do
    open_spot_score(open_spots) * 0.70 +
      waivable_score(length(easily_waivable)) * 0.30
  end

  @doc "Combines the four supporting calculations at AVC's 30/40/20/10 weights."
  @spec combine_components(map()) :: float()
  def combine_components(components) when is_map(components) do
    Enum.reduce(@component_weights, 0.0, fn {component, weight}, total ->
      total + Map.fetch!(components, component) * weight
    end)
  end

  defp open_spot_score(open_spots) when open_spots in 1..2, do: 100.0
  defp open_spot_score(3), do: 90.0
  defp open_spot_score(0), do: 75.0
  defp open_spot_score(4), do: 75.0
  defp open_spot_score(open_spots) when open_spots >= 5, do: 50.0

  defp waivable_score(0), do: 50.0
  defp waivable_score(1), do: 75.0
  defp waivable_score(2), do: 90.0
  defp waivable_score(count) when count >= 3, do: 100.0
end

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

Next: Asset management →

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