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