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