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