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.
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.
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.
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.
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.
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.
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