Files
adaptive-pricing/projects/coulomb-pricing/observatory/ltv.py

211 lines
8.4 KiB
Python

from __future__ import annotations
from decimal import Decimal
from typing import Any
from ._repo_root import ensure_repo_root_on_syspath
from .boundary import build_boundary_policy
from .models import EconomicsSnapshot, PricingModel
ensure_repo_root_on_syspath()
from adaptive_pricing_core.boundary_engine import PricingConfiguration # noqa: E402
from adaptive_pricing_core.comparable_ltv import ( # noqa: E402
ComparableCustomerProfile,
LTVPolicy,
SensitivityCase,
compare_pricing_configurations,
)
def _serialize(value: Any) -> Any:
if isinstance(value, Decimal):
return str(value)
if hasattr(value, "__dataclass_fields__"):
return {key: _serialize(getattr(value, key)) for key in value.__dataclass_fields__}
if isinstance(value, tuple):
return [_serialize(item) for item in value]
if isinstance(value, list):
return [_serialize(item) for item in value]
if isinstance(value, dict):
return {key: _serialize(item) for key, item in value.items()}
return value
def _decimal(value: Decimal | str | int | float | None) -> Decimal:
if value in (None, ""):
return Decimal("0")
return Decimal(str(value))
def _usage_component(model: PricingModel):
return next((component for component in model.charge_components if component.kind == "usage"), None)
def _included_units(model: PricingModel, members_per_customer: int) -> Decimal | None:
usage = _usage_component(model)
if not usage or usage.included_units is None:
return None
return usage.included_units * Decimal(members_per_customer)
def _usage_unit_price(model: PricingModel) -> Decimal | None:
usage = _usage_component(model)
if not usage or usage.unit_price is None:
return None
return usage.unit_price
def _usage_unit_cost(records: list[dict[str, Any]], period: str) -> Decimal:
period_rows = [row for row in records if row.get("period") == period]
total_units = sum(_decimal(row.get("tokens")) for row in period_rows)
total_cost = sum(_decimal(row.get("cost_eur")) for row in period_rows)
if total_units <= Decimal("0"):
return Decimal("0")
return total_cost / total_units
def _payment_fee_rate(snapshot: EconomicsSnapshot) -> Decimal:
if snapshot.monthly_revenue <= Decimal("0"):
return Decimal("0")
return (snapshot.monthly_payment_processing_cost / snapshot.monthly_revenue) * Decimal("100")
def _profile(raw: dict[str, Any]) -> ComparableCustomerProfile:
return ComparableCustomerProfile(
id=raw["id"],
name=raw["name"],
segment=raw["segment"],
eligible_model_ids=tuple(raw.get("eligible_model_ids", [])),
members_per_customer=int(raw.get("members_per_customer", 1)),
expected_monthly_usage_units=_decimal(raw.get("expected_monthly_usage_units")),
usage_variance_pct=_decimal(raw.get("usage_variance_pct")),
monthly_churn_pct=_decimal(raw.get("monthly_churn_pct")),
monthly_default_pct=_decimal(raw.get("monthly_default_pct")),
monthly_support_cost=_decimal(raw.get("monthly_support_cost")),
monthly_risk_cost=_decimal(raw.get("monthly_risk_cost")),
acquisition_cost=_decimal(raw.get("acquisition_cost")),
upfront_investment_cost=_decimal(raw.get("upfront_investment_cost")),
allocated_fixed_cost=_decimal(raw["allocated_fixed_cost"]) if raw.get("allocated_fixed_cost") else None,
notes=raw.get("notes", ""),
)
def _sensitivity_case(raw: dict[str, Any]) -> SensitivityCase:
return SensitivityCase(
id=raw["id"],
name=raw["name"],
usage_multiplier=_decimal(raw.get("usage_multiplier", "1")),
usage_variance_delta_pct=_decimal(raw.get("usage_variance_delta_pct")),
monthly_churn_delta_pct=_decimal(raw.get("monthly_churn_delta_pct")),
monthly_default_delta_pct=_decimal(raw.get("monthly_default_delta_pct")),
monthly_support_cost_delta=_decimal(raw.get("monthly_support_cost_delta")),
monthly_risk_cost_delta=_decimal(raw.get("monthly_risk_cost_delta")),
)
def _ltv_policy(raw: dict[str, Any]) -> LTVPolicy:
return LTVPolicy(
horizon_months=int(raw.get("horizon_months", 24)),
monthly_discount_rate_pct=_decimal(raw.get("monthly_discount_rate_pct", "1.0")),
required_improvement_factor=_decimal(raw.get("required_improvement_factor", "1.05")),
)
def _configuration(
model: PricingModel,
profile: ComparableCustomerProfile,
snapshot: EconomicsSnapshot,
usage_unit_cost: Decimal,
) -> PricingConfiguration:
members_per_customer = max(profile.members_per_customer, 1)
per_member_fixed_cost = (
snapshot.monthly_infrastructure_cost / snapshot.active_members
if snapshot.active_members
else snapshot.monthly_infrastructure_cost
)
allocated_fixed_cost = (
profile.allocated_fixed_cost
if profile.allocated_fixed_cost is not None
else per_member_fixed_cost * Decimal(members_per_customer)
)
return PricingConfiguration(
model=model,
segment=profile.segment,
expected_usage_units=profile.expected_monthly_usage_units,
expected_usage_variance_pct=profile.usage_variance_pct,
allocated_fixed_cost=allocated_fixed_cost,
unit_cost=usage_unit_cost,
support_cost=profile.monthly_support_cost,
risk_cost=profile.monthly_risk_cost,
payment_fee_rate_pct=_payment_fee_rate(snapshot),
access_fee_amount=model.access_fee_amount * Decimal(members_per_customer),
included_units=_included_units(model, members_per_customer),
usage_unit_price=_usage_unit_price(model),
)
def build_ltv_simulations(
snapshot: EconomicsSnapshot,
models: list[PricingModel],
usage_records: list[dict[str, Any]],
scenario_catalog: dict[str, Any],
) -> dict[str, Any]:
policy = _ltv_policy(scenario_catalog)
boundary_policy = build_boundary_policy(snapshot)
sensitivity_cases = tuple(_sensitivity_case(item) for item in scenario_catalog.get("sensitivity_cases", []))
observed_usage_unit_cost = _usage_unit_cost(usage_records, snapshot.period)
profile_results = []
for raw_profile in scenario_catalog.get("profiles", []):
profile = _profile(raw_profile)
configurations = [
_configuration(model, profile, snapshot, observed_usage_unit_cost)
for model in models
if model.status in ("active", "candidate")
]
profile_results.append(
compare_pricing_configurations(
configurations,
profile,
boundary_policy,
policy,
sensitivity_cases=sensitivity_cases,
)
)
primary = profile_results[0] if profile_results else None
primary_scenarios = list(primary.comparisons) if primary else []
active_model = next((model for model in models if model.status == "active"), None)
best_margin = max(primary_scenarios, key=lambda item: item.base_monthly_margin, default=None)
best_ltv = max(
primary_scenarios,
key=lambda item: item.average_comparable_customer_lifetime_value,
default=None,
)
return _serialize({
"period": snapshot.period,
"currency": snapshot.currency,
"required_improvement_factor": policy.required_improvement_factor,
"horizon_months": policy.horizon_months,
"monthly_discount_rate_pct": policy.monthly_discount_rate_pct,
"active_scenario_id": active_model.id if active_model else None,
"best_margin_scenario_id": best_margin.model_id if best_margin else None,
"best_ltv_scenario_id": best_ltv.model_id if best_ltv else None,
"reference_model_id": primary.reference_model_id if primary else None,
"primary_profile_id": primary.profile.id if primary else None,
"scenarios": primary_scenarios,
"profile_comparisons": profile_results,
"calibration": {
"observed_usage_unit_cost": observed_usage_unit_cost,
"observed_payment_fee_rate_pct": _payment_fee_rate(snapshot),
"profile_count": len(profile_results),
},
"notes": [
scenario_catalog.get("notes", ""),
"Primary scenarios expose the first configured comparable-customer profile for backward-compatible UI consumers.",
"Profile comparisons compare candidate models using discounted seller LTV rather than only current-period gross margin.",
],
})