Skip to content

Advanced Analytics API

Monte Carlo simulation, decision trees, purchase price allocation, goodwill, litigation damages, and transfer pricing.

Monte Carlo Sensitivity

monte_carlo

Monte Carlo simulation for valuation under uncertainty.

Re-exports monte_carlo_valuation from src.core.statistics and adds monte_carlo_sensitivity for sensitivity analysis on valuation functions.

Functions

monte_carlo_sensitivity(valuation_fn: Callable[[dict], float], base_params: dict, distributions: dict, iterations: int = 10000, seed: int | None = None) -> dict

Run Monte Carlo sensitivity analysis on a valuation function.

Unlike monte_carlo_valuation which simulates all inputs, this function varies only the specified parameters (distributions) while keeping others at their base values.

Parameters:

Name Type Description Default
valuation_fn Callable[[dict], float]

Callable that takes a dict of parameter names to values.

required
base_params dict

Base/default values for all parameters.

required
distributions dict

Dict mapping parameter names to distribution specs. Each spec: {"distribution": str, "params": dict}. Supported: "normal", "uniform", "triangular", "lognormal".

required
iterations int

Number of simulation iterations (1000-100000).

10000
seed int | None

Random seed for reproducibility.

None

Returns:

Type Description
dict

Dict with value (mean), method, formula_reference, steps, assumptions,

dict

statistics, and sensitivity_ranking of parameters by impact.

Source code in src/advanced/monte_carlo.py
def monte_carlo_sensitivity(
    valuation_fn: Callable[[dict], float],
    base_params: dict,
    distributions: dict,
    iterations: int = 10000,
    seed: int | None = None,
) -> dict:
    """Run Monte Carlo sensitivity analysis on a valuation function.

    Unlike monte_carlo_valuation which simulates all inputs, this function
    varies only the specified parameters (distributions) while keeping others
    at their base values.

    Args:
        valuation_fn: Callable that takes a dict of parameter names to values.
        base_params: Base/default values for all parameters.
        distributions: Dict mapping parameter names to distribution specs.
            Each spec: {"distribution": str, "params": dict}.
            Supported: "normal", "uniform", "triangular", "lognormal".
        iterations: Number of simulation iterations (1000-100000).
        seed: Random seed for reproducibility.

    Returns:
        Dict with value (mean), method, formula_reference, steps, assumptions,
        statistics, and sensitivity_ranking of parameters by impact.
    """
    if iterations < 1000:
        raise ValueError("iterations must be >= 1000")
    if iterations > 100000:
        raise ValueError("iterations must be <= 100000")
    if not distributions:
        raise ValueError("distributions must not be empty")

    rng = np.random.default_rng(seed)

    samples: dict[str, np.ndarray] = {}
    for name, dist_spec in distributions.items():
        dist = dist_spec["distribution"]
        params = dist_spec["params"]

        if dist == "normal":
            samples[name] = rng.normal(loc=params["mean"], scale=params["std"], size=iterations)
        elif dist == "uniform":
            samples[name] = rng.uniform(low=params["low"], high=params["high"], size=iterations)
        elif dist == "triangular":
            samples[name] = rng.triangular(
                left=params["low"], mode=params["mode"],
                right=params["high"], size=iterations,
            )
        elif dist == "lognormal":
            samples[name] = rng.lognormal(mean=params["mean"], sigma=params["sigma"], size=iterations)
        else:
            raise ValueError(f"Unsupported distribution: {dist}")

    results = np.zeros(iterations)
    for i in range(iterations):
        params = dict(base_params)
        for name in samples:
            params[name] = float(samples[name][i])
        result = valuation_fn(params)
        val = result.value if hasattr(result, "value") else result
        results[i] = float(val)

    mean_val = float(np.mean(results))
    std_val = float(np.std(results))
    p5 = float(np.percentile(results, 5))
    p50 = float(np.percentile(results, 50))
    p95 = float(np.percentile(results, 95))

    sensitivity_ranking: list[dict[str, float | str | None]] = []
    for name, values in samples.items():
        corr = float(np.corrcoef(values, results)[0, 1])
        sensitivity_ranking.append({
            "parameter": name,
            "correlation": round(corr, 4),
            "abs_correlation": round(abs(corr), 4),
            "base_value": base_params.get(name),
            "simulated_mean": round(float(np.mean(values)), 4),
            "simulated_std": round(float(np.std(values)), 4),
        })

    sensitivity_ranking.sort(key=lambda x: x["abs_correlation"], reverse=True)  # type: ignore[arg-type,return-value]

    return {
        "value": round(mean_val, 2),
        "method": "Monte Carlo Sensitivity Analysis",
        "formula_reference": "Ch 2.4, Appendix A.11, A.15",
        "steps": [
            {"step": 1, "description": f"Monte Carlo sensitivity analysis with {iterations} iterations"},
            {"step": 2, "description": f"Varying {len(distributions)} parameters"},
            {"step": 3, "description": "Mean valuation result", "value": round(mean_val, 2)},
        ],
        "assumptions": {
            "iterations": iterations,
            "seed": seed,
            "base_params": base_params,
            "distributions": distributions,
        },
        "statistics": {
            "mean": round(mean_val, 2),
            "std": round(std_val, 2),
            "median": round(p50, 2),
            "percentile_5": round(p5, 2),
            "percentile_50": round(p50, 2),
            "percentile_95": round(p95, 2),
            "confidence_interval_90": [round(p5, 2), round(p95, 2)],
            "min": round(float(np.min(results)), 2),
            "max": round(float(np.max(results)), 2),
        },
        "sensitivity_ranking": sensitivity_ranking,
    }

Purchase Price Allocation

purchase_price_alloc

Purchase Price Allocation (PPA) waterfall analysis.

Implements Section 10.2 and Appendix A.8: Full allocation of purchase price to tangible assets, identified intangibles, liabilities, and residual goodwill.

Classes

IdentifiedIntangible

Bases: BaseModel

PPAInput

Bases: BaseModel

Functions

purchase_price_allocation(purchase_price: float, tangible_assets_fv: float, identified_intangibles: list[dict], liabilities_fv: float = 0) -> ValuationResult

Perform full purchase price allocation waterfall.

Allocates the purchase price across: 1. Tangible assets at fair value 2. Identified intangible assets at fair value 3. Assumed liabilities at fair value 4. Goodwill as the residual

Parameters:

Name Type Description Default
purchase_price float

Total acquisition consideration.

required
tangible_assets_fv float

Fair value of all tangible assets acquired.

required
identified_intangibles list[dict]

List of dicts with keys: name, value, method.

required
liabilities_fv float

Fair value of liabilities assumed.

0

Returns:

Type Description
ValuationResult

ValuationResult with full allocation breakdown and percentages.

Raises:

Type Description
ValueError

If inputs are invalid or result in negative goodwill.

Example (Book Example): $100M purchase, $15M tangible, $60M identified intangibles, $0 liabilities Net identifiable = 15M + 60M - 0 = 75M Goodwill = 100M - 75M = 25M

Source code in src/advanced/purchase_price_alloc.py
def purchase_price_allocation(
    purchase_price: float,
    tangible_assets_fv: float,
    identified_intangibles: list[dict],
    liabilities_fv: float = 0,
) -> ValuationResult:
    """Perform full purchase price allocation waterfall.

    Allocates the purchase price across:
    1. Tangible assets at fair value
    2. Identified intangible assets at fair value
    3. Assumed liabilities at fair value
    4. Goodwill as the residual

    Args:
        purchase_price: Total acquisition consideration.
        tangible_assets_fv: Fair value of all tangible assets acquired.
        identified_intangibles: List of dicts with keys: name, value, method.
        liabilities_fv: Fair value of liabilities assumed.

    Returns:
        ValuationResult with full allocation breakdown and percentages.

    Raises:
        ValueError: If inputs are invalid or result in negative goodwill.

    Example (Book Example):
        $100M purchase, $15M tangible, $60M identified intangibles, $0 liabilities
        Net identifiable = 15M + 60M - 0 = 75M
        Goodwill = 100M - 75M = 25M
    """
    validated_intangibles = [IdentifiedIntangible(**item) for item in identified_intangibles]
    PPAInput(
        purchase_price=purchase_price,
        tangible_assets_fv=tangible_assets_fv,
        identified_intangibles=validated_intangibles,
        liabilities_fv=liabilities_fv,
    )

    total_intangibles = sum(iv.value for iv in validated_intangibles)
    net_identifiable = tangible_assets_fv + total_intangibles - liabilities_fv

    gw_result = goodwill(purchase_price, net_identifiable)
    goodwill_value = gw_result.value

    tangible_assets_fv + total_intangibles + goodwill_value
    pct_tangible = (tangible_assets_fv / purchase_price * 100) if purchase_price else 0
    pct_intangible = (total_intangibles / purchase_price * 100) if purchase_price else 0
    pct_goodwill = (goodwill_value / purchase_price * 100) if purchase_price else 0

    steps: list[dict] = [
        {"item": "Purchase Price", "value": purchase_price},
        {"item": "Tangible Assets", "value": tangible_assets_fv},
        {"item": "Identified Intangibles", "value": total_intangibles},
        {"item": "Liabilities", "value": -liabilities_fv},
        {"item": "Net Identifiable Assets", "value": net_identifiable},
        {"item": "Goodwill (residual)", "value": goodwill_value},
    ]

    for iv in validated_intangibles:
        steps.insert(2, {"item": f"  - {iv.name} ({iv.method})", "value": iv.value})

    allocation = {
        "tangible": f"{pct_tangible:.1f}%",
        "intangible": f"{pct_intangible:.1f}%",
        "goodwill": f"{pct_goodwill:.1f}%",
    }

    return ValuationResult(
        value=goodwill_value,
        method="Purchase Price Allocation",
        formula_reference="Ch 10.2, Appendix A.8",
        steps=steps,
        assumptions={"allocation": allocation},
    )

Goodwill

goodwill

Goodwill calculation as residual of purchase price over net identifiable assets.

Implements Section 10.1: Goodwill = Purchase Price - Fair Value of Net Identifiable Assets. Raises ValueError for bargain purchases (negative goodwill).

Classes

GoodwillInput

Bases: BaseModel

Functions

goodwill(purchase_price: float, fair_value_net_identifiable_assets: float) -> ValuationResult

Calculate goodwill as the residual of purchase price over fair value of net identifiable assets.

Goodwill represents the premium paid for unidentifiable intangible assets such as synergies, assembled workforce, and brand reputation that cannot be separately identified.

Parameters:

Name Type Description Default
purchase_price float

Total acquisition consideration paid.

required
fair_value_net_identifiable_assets float

Fair value of all identifiable assets minus liabilities.

required

Returns:

Type Description
ValuationResult

ValuationResult with goodwill amount, calculation steps, and assumptions.

Raises:

Type Description
ValueError

If purchase_price < fair_value_net_identifiable_assets (bargain purchase).

ValueError

If purchase_price <= 0 or fair_value_net_identifiable_assets < 0.

Example

result = goodwill(100_000_000, 75_000_000) result.value 25000000.0

Source code in src/advanced/goodwill.py
def goodwill(purchase_price: float, fair_value_net_identifiable_assets: float) -> ValuationResult:
    """Calculate goodwill as the residual of purchase price over fair value of net identifiable assets.

    Goodwill represents the premium paid for unidentifiable intangible assets such as
    synergies, assembled workforce, and brand reputation that cannot be separately identified.

    Args:
        purchase_price: Total acquisition consideration paid.
        fair_value_net_identifiable_assets: Fair value of all identifiable assets minus liabilities.

    Returns:
        ValuationResult with goodwill amount, calculation steps, and assumptions.

    Raises:
        ValueError: If purchase_price < fair_value_net_identifiable_assets (bargain purchase).
        ValueError: If purchase_price <= 0 or fair_value_net_identifiable_assets < 0.

    Example:
        >>> result = goodwill(100_000_000, 75_000_000)
        >>> result.value
        25000000.0
    """
    GoodwillInput(purchase_price=purchase_price, fair_value_net_identifiable_assets=fair_value_net_identifiable_assets)

    goodwill_value = purchase_price - fair_value_net_identifiable_assets

    if goodwill_value < 0:
        raise ValueError(
            f"Bargain purchase detected: purchase_price ({purchase_price}) < "
            f"fair_value_net_identifiable_assets ({fair_value_net_identifiable_assets}). "
            f"Resulting goodwill would be negative ({goodwill_value}). "
            f"Under ASC 805 / IFRS 3, this requires reassessment before recognizing a gain."
        )

    return ValuationResult(
        value=round(goodwill_value, 2),
        method="Goodwill (Residual Method)",
        formula_reference="Ch 10.1, ASC 805-30-30-1",
        steps=[
            {"step": 1, "description": "Purchase Price", "value": purchase_price},
            {
                "step": 2,
                "description": "Fair Value of Net Identifiable Assets",
                "value": fair_value_net_identifiable_assets,
            },
            {
                "step": 3,
                "description": "Goodwill = Purchase Price - Net Identifiable Assets",
                "calculation": f"{purchase_price} - {fair_value_net_identifiable_assets}",
            },
            {"step": 4, "description": "Goodwill", "value": round(goodwill_value, 2)},
        ],
        assumptions={
            "purchase_price": purchase_price,
            "fair_value_net_identifiable_assets": fair_value_net_identifiable_assets,
        },
    )

Litigation Damages

litigation

Patent infringement damages calculation.

Implements Section 15.2: Calculates total damages including pre-judgment interest on lost profits or reasonable royalty over the infringement period.

Classes

PatentDamagesInput

Bases: BaseModel

Functions

patent_infringement_damages(lost_profits_or_royalty: float, infringement_period: int, discount_rate: float, prejudgment_interest_rate: float) -> ValuationResult

Calculate patent infringement damages with pre-judgment interest.

Total damages = Present value of lost profits/royalty over infringement period + Pre-judgment interest on the damages amount.

The present value of the lost profits or reasonable royalty is calculated as an annuity over the infringement period. Pre-judgment interest is then applied to compensate for the time value of money from the date of infringement to the date of judgment.

Parameters:

Name Type Description Default
lost_profits_or_royalty float

Annual lost profits or reasonable royalty amount.

required
infringement_period int

Number of years the infringement occurred.

required
discount_rate float

Discount rate for present value calculation.

required
prejudgment_interest_rate float

Pre-judgment interest rate.

required

Returns:

Type Description
ValuationResult

ValuationResult with total damages, PV of lost profits, and interest amount.

Raises:

Type Description
ValueError

If any input is invalid.

Example

result = patent_infringement_damages(1_000_000, 5, 0.10, 0.05) result.value # total damages with interest

Source code in src/advanced/litigation.py
def patent_infringement_damages(
    lost_profits_or_royalty: float,
    infringement_period: int,
    discount_rate: float,
    prejudgment_interest_rate: float,
) -> ValuationResult:
    """Calculate patent infringement damages with pre-judgment interest.

    Total damages = Present value of lost profits/royalty over infringement period
                    + Pre-judgment interest on the damages amount.

    The present value of the lost profits or reasonable royalty is calculated as
    an annuity over the infringement period. Pre-judgment interest is then applied
    to compensate for the time value of money from the date of infringement to
    the date of judgment.

    Args:
        lost_profits_or_royalty: Annual lost profits or reasonable royalty amount.
        infringement_period: Number of years the infringement occurred.
        discount_rate: Discount rate for present value calculation.
        prejudgment_interest_rate: Pre-judgment interest rate.

    Returns:
        ValuationResult with total damages, PV of lost profits, and interest amount.

    Raises:
        ValueError: If any input is invalid.

    Example:
        >>> result = patent_infringement_damages(1_000_000, 5, 0.10, 0.05)
        >>> result.value  # total damages with interest
    """
    PatentDamagesInput(
        lost_profits_or_royalty=lost_profits_or_royalty,
        infringement_period=infringement_period,
        discount_rate=discount_rate,
        prejudgment_interest_rate=prejudgment_interest_rate,
    )

    pv_result = annuity_pv(lost_profits_or_royalty, discount_rate, infringement_period)
    pv_damages = pv_result.value

    prejudgment_interest = pv_damages * ((1 + prejudgment_interest_rate) ** infringement_period - 1)
    total_damages = pv_damages + prejudgment_interest

    steps = [
        {
            "step": 1,
            "description": "Annual Lost Profits / Reasonable Royalty",
            "value": lost_profits_or_royalty,
        },
        {"step": 2, "description": "Infringement Period (years)", "value": infringement_period},
        {"step": 3, "description": "Discount Rate", "value": discount_rate},
        {
            "step": 4,
            "description": "PV of Lost Profits (annuity)",
            "value": round(pv_damages, 2),
        },
        {
            "step": 5,
            "description": "Pre-judgment Interest Rate",
            "value": prejudgment_interest_rate,
        },
        {
            "step": 6,
            "description": "Pre-judgment Interest",
            "calculation": (
                f"{pv_damages} * ((1 + {prejudgment_interest_rate})"
                f"^{infringement_period} - 1)"
            ),
            "value": round(prejudgment_interest, 2),
        },
        {
            "step": 7,
            "description": "Total Damages",
            "calculation": f"{pv_damages} + {prejudgment_interest}",
            "value": round(total_damages, 2),
        },
    ]

    return ValuationResult(
        value=round(total_damages, 2),
        method="Patent Infringement Damages",
        formula_reference="Ch 15.2",
        steps=steps,
        assumptions={
            "lost_profits_or_royalty": lost_profits_or_royalty,
            "infringement_period": infringement_period,
            "discount_rate": discount_rate,
            "prejudgment_interest_rate": prejudgment_interest_rate,
            "pv_damages": round(pv_damages, 2),
            "prejudgment_interest": round(prejudgment_interest, 2),
        },
    )

Royalty Benchmarking

royalty_benchmark

Royalty rate benchmarking and adjustment.

Implements Section 6.1-6.3 and Appendix A.10: - Built-in benchmark database for common IP types and industries - 25% rule for royalty rate estimation - Adjustment factors for customizing base rates

Functions

royalty_rate_benchmark(ip_type: str, industry: str, comparable_database: list[dict] | None = None) -> ValuationResult

Get benchmark royalty rate range by IP type and industry.

Uses built-in benchmark database compiled from RoyaltyStat, ktMINE, and industry surveys. Can be supplemented with user-provided comparables.

Parameters:

Name Type Description Default
ip_type str

One of "patent", "trademark", "copyright", "trade_secret", "technology".

required
industry str

Industry sector name.

required
comparable_database list[dict] | None

Optional list of {"rate": float, "source": str} for custom comparables.

None

Returns:

Type Description
ValuationResult

ValuationResult with recommended rate range, median, and source data.

Raises:

Type Description
ValueError

If ip_type is invalid.

Example

result = royalty_rate_benchmark("patent", "pharmaceutical") result.assumptions["recommended_range"] (0.05, 0.15)

Source code in src/advanced/royalty_benchmark.py
def royalty_rate_benchmark(
    ip_type: str,
    industry: str,
    comparable_database: list[dict] | None = None,
) -> ValuationResult:
    """Get benchmark royalty rate range by IP type and industry.

    Uses built-in benchmark database compiled from RoyaltyStat, ktMINE,
    and industry surveys. Can be supplemented with user-provided comparables.

    Args:
        ip_type: One of "patent", "trademark", "copyright", "trade_secret", "technology".
        industry: Industry sector name.
        comparable_database: Optional list of {"rate": float, "source": str} for custom comparables.

    Returns:
        ValuationResult with recommended rate range, median, and source data.

    Raises:
        ValueError: If ip_type is invalid.

    Example:
        >>> result = royalty_rate_benchmark("patent", "pharmaceutical")
        >>> result.assumptions["recommended_range"]
        (0.05, 0.15)
    """
    RoyaltyBenchmarkInput(ip_type=ip_type, industry=industry)

    ip_data = BENCHMARK_DATABASE.get(ip_type, {})
    industry_data = ip_data.get(industry.lower(), ip_data.get("general", {}))

    if not industry_data:
        industry_data = {"min_rate": 0.02, "max_rate": 0.08, "median": 0.05, "source": "Default benchmark"}

    min_rate = industry_data["min_rate"]
    max_rate = industry_data["max_rate"]
    median_rate = industry_data["median"]
    source = industry_data["source"]

    if comparable_database:
        user_rates = [c["rate"] for c in comparable_database if "rate" in c]
        if user_rates:
            min_rate = min(min_rate, *user_rates)
            max_rate = max(max_rate, *user_rates)
            median_rate = sorted(user_rates)[len(user_rates) // 2]
            source = f"{source}; User comparables ({len(user_rates)} entries)"

    steps = [
        {"step": 1, "description": f"IP Type: {ip_type}"},
        {"step": 2, "description": f"Industry: {industry}"},
        {"step": 3, "description": f"Benchmark source: {source}"},
        {"step": 4, "description": "Recommended range", "calculation": f"{min_rate:.1%} - {max_rate:.1%}"},
        {"step": 5, "description": "Median rate", "value": median_rate},
    ]

    return ValuationResult(
        value=median_rate,
        method="Royalty Rate Benchmark",
        formula_reference="Ch 6.2, Appendix A.10",
        steps=steps,
        assumptions={
            "ip_type": ip_type,
            "industry": industry,
            "recommended_range": (min_rate, max_rate),
            "median_rate": median_rate,
            "source": source,
        },
    )

adjust_royalty_rate(base_rate: float, adjustment_factors: dict) -> ValuationResult

Adjust base royalty rate for specific deal factors.

Each factor in adjustment_factors is a multiplier: - profit_margin: Higher margins support higher rates (typical range 0.8-1.3) - exclusivity: Exclusive licenses command premium (typical range 1.0-1.5) - market_conditions: Favorable markets support higher rates (typical range 0.8-1.2) - term: Longer terms may reduce per-period rate (typical range 0.8-1.2) - geographic_scope: Broader scope increases rate (typical range 0.8-1.4)

Adjusted Rate = Base Rate * product(all factors)

Parameters:

Name Type Description Default
base_rate float

Base royalty rate from benchmark (0 < rate <= 1).

required
adjustment_factors dict

Dict of factor name to multiplier value.

required

Returns:

Type Description
ValuationResult

ValuationResult with adjusted rate and factor breakdown.

Raises:

Type Description
ValueError

If base_rate is invalid.

Example

adjust_royalty_rate(0.05, {"profit_margin": 1.2, "exclusivity": 1.3}) ValuationResult(value=0.078, ...)

Source code in src/advanced/royalty_benchmark.py
def adjust_royalty_rate(
    base_rate: float,
    adjustment_factors: dict,
) -> ValuationResult:
    """Adjust base royalty rate for specific deal factors.

    Each factor in adjustment_factors is a multiplier:
    - profit_margin: Higher margins support higher rates (typical range 0.8-1.3)
    - exclusivity: Exclusive licenses command premium (typical range 1.0-1.5)
    - market_conditions: Favorable markets support higher rates (typical range 0.8-1.2)
    - term: Longer terms may reduce per-period rate (typical range 0.8-1.2)
    - geographic_scope: Broader scope increases rate (typical range 0.8-1.4)

    Adjusted Rate = Base Rate * product(all factors)

    Args:
        base_rate: Base royalty rate from benchmark (0 < rate <= 1).
        adjustment_factors: Dict of factor name to multiplier value.

    Returns:
        ValuationResult with adjusted rate and factor breakdown.

    Raises:
        ValueError: If base_rate is invalid.

    Example:
        >>> adjust_royalty_rate(0.05, {"profit_margin": 1.2, "exclusivity": 1.3})
        ValuationResult(value=0.078, ...)
    """
    RoyaltyAdjustmentInput(base_rate=base_rate)

    valid_factors = {"profit_margin", "exclusivity", "market_conditions", "term", "geographic_scope"}
    invalid = set(adjustment_factors.keys()) - valid_factors
    if invalid:
        raise ValueError(f"Unknown adjustment factors: {invalid}. Valid: {valid_factors}")

    adjusted = base_rate
    factor_steps: list[dict] = []
    for factor_name, multiplier in adjustment_factors.items():
        adjusted *= multiplier
        factor_steps.append({
            "step": len(factor_steps) + 1,
            "description": f"Apply {factor_name} factor",
            "calculation": f"{adjusted / multiplier:.4f} * {multiplier} = {adjusted:.4f}",
            "value": round(adjusted, 6),
        })

    steps = [
        {"step": 1, "description": "Base royalty rate", "value": base_rate},
    ] + factor_steps + [
        {"step": len(factor_steps) + 2, "description": "Adjusted royalty rate", "value": round(adjusted, 6)},
    ]

    return ValuationResult(
        value=round(adjusted, 6),
        method="Adjusted Royalty Rate",
        formula_reference="Ch 6.3, Appendix A.10",
        steps=steps,
        assumptions={
            "base_rate": base_rate,
            "adjustment_factors": adjustment_factors,
            "adjusted_rate": round(adjusted, 6),
        },
    )

twenty_five_percent_rule(licensee_expected_profit: float, profit_attribution_to_ip: float = 1.0) -> ValuationResult

Apply the 25% rule of thumb for royalty rate estimation.

The 25% rule suggests the licensor should receive 25% of the licensee's expected profit attributable to the licensed IP.

Royalty = Licensee Expected Profit * Profit Attribution to IP * 25%

Note: This rule has been criticized and rejected by some courts (e.g., Uniloc v. Microsoft), but remains a useful starting point for negotiations.

Parameters:

Name Type Description Default
licensee_expected_profit float

Expected profit from using the IP (> 0).

required
profit_attribution_to_ip float

Fraction of profit attributable to IP (0-1).

1.0

Returns:

Type Description
ValuationResult

ValuationResult with royalty amount.

Raises:

Type Description
ValueError

If inputs are invalid.

Example

result = twenty_five_percent_rule(10_000_000, 0.8) result.value 2000000.0

Source code in src/advanced/royalty_benchmark.py
def twenty_five_percent_rule(
    licensee_expected_profit: float,
    profit_attribution_to_ip: float = 1.0,
) -> ValuationResult:
    """Apply the 25% rule of thumb for royalty rate estimation.

    The 25% rule suggests the licensor should receive 25% of the licensee's
    expected profit attributable to the licensed IP.

    Royalty = Licensee Expected Profit * Profit Attribution to IP * 25%

    Note: This rule has been criticized and rejected by some courts (e.g.,
    Uniloc v. Microsoft), but remains a useful starting point for negotiations.

    Args:
        licensee_expected_profit: Expected profit from using the IP (> 0).
        profit_attribution_to_ip: Fraction of profit attributable to IP (0-1).

    Returns:
        ValuationResult with royalty amount.

    Raises:
        ValueError: If inputs are invalid.

    Example:
        >>> result = twenty_five_percent_rule(10_000_000, 0.8)
        >>> result.value
        2000000.0
    """
    TwentyFivePercentInput(
        licensee_expected_profit=licensee_expected_profit,
        profit_attribution_to_ip=profit_attribution_to_ip,
    )

    ip_profit = licensee_expected_profit * profit_attribution_to_ip
    royalty = ip_profit * 0.25

    steps = [
        {"step": 1, "description": "Licensee Expected Profit", "value": licensee_expected_profit},
        {"step": 2, "description": "Profit Attribution to IP", "value": profit_attribution_to_ip},
        {"step": 3, "description": "IP-Attributable Profit",
         "calculation": f"{licensee_expected_profit} * {profit_attribution_to_ip}",
         "value": round(ip_profit, 2)},
        {"step": 4, "description": "Apply 25% Rule", "calculation": f"{ip_profit} * 0.25"},
        {"step": 5, "description": "Estimated Royalty", "value": round(royalty, 2)},
    ]

    return ValuationResult(
        value=round(royalty, 2),
        method="25% Rule",
        formula_reference="Ch 6.1",
        steps=steps,
        assumptions={
            "licensee_expected_profit": licensee_expected_profit,
            "profit_attribution_to_ip": profit_attribution_to_ip,
            "rule_percentage": 0.25,
        },
    )

Impairment Testing

impairment_testing

Goodwill and intangible asset impairment testing.

Implements Section 10.4 and Appendix A.9: - ASC 350: Goodwill impairment = Carrying Value - Fair Value (one-step test) - IAS 36: Uses recoverable amount (higher of FV less costs to sell and value in use)

Classes

CGUImpairmentInputs

Bases: BaseModel

Inputs for CGU-level impairment allocation.

FVLCTSInputs

Bases: BaseModel

Inputs for fair value less costs to sell.

ValueInUseInputs

Bases: BaseModel

Inputs for IAS 36 value in use calculation.

Functions

cash_generating_unit_impairment(cgu_carrying_value: float, cgu_recoverable_amount: float, goodwill_allocated: float, other_assets: list[dict]) -> ValuationResult

Allocate impairment loss at the CGU level per IAS 36.

When a CGU is impaired, the loss is allocated: 1. First to reduce goodwill allocated to the CGU 2. Then pro rata to other assets based on carrying amounts

No asset is reduced below the highest of: - Its fair value less costs to sell - Its value in use - Zero

Parameters:

Name Type Description Default
cgu_carrying_value float

Total carrying value of the CGU (including goodwill).

required
cgu_recoverable_amount float

Recoverable amount of the CGU.

required
goodwill_allocated float

Amount of goodwill allocated to this CGU.

required
other_assets list[dict]

List of dicts with 'name' and 'carrying_value' for each non-goodwill asset in the CGU.

required

Returns:

Type Description
ValuationResult

ValuationResult with total impairment, allocation details, and post-impairment carrying values.

Raises:

Type Description
ValueError

If inputs are invalid.

Example

result = cash_generating_unit_impairment( ... cgu_carrying_value=100_000_000, ... cgu_recoverable_amount=80_000_000, ... goodwill_allocated=15_000_000, ... other_assets=[ ... {"name": "Patents", "carrying_value": 40_000_000}, ... {"name": "Equipment", "carrying_value": 45_000_000}, ... ], ... ) result.value # Total impairment 20000000.0

Reference

IAS 36.104-108: Impairment loss allocation to a CGU.

Source code in src/advanced/impairment_testing.py
def cash_generating_unit_impairment(
    cgu_carrying_value: float,
    cgu_recoverable_amount: float,
    goodwill_allocated: float,
    other_assets: list[dict],
) -> ValuationResult:
    """Allocate impairment loss at the CGU level per IAS 36.

    When a CGU is impaired, the loss is allocated:
    1. First to reduce goodwill allocated to the CGU
    2. Then pro rata to other assets based on carrying amounts

    No asset is reduced below the highest of:
    - Its fair value less costs to sell
    - Its value in use
    - Zero

    Args:
        cgu_carrying_value: Total carrying value of the CGU (including goodwill).
        cgu_recoverable_amount: Recoverable amount of the CGU.
        goodwill_allocated: Amount of goodwill allocated to this CGU.
        other_assets: List of dicts with 'name' and 'carrying_value' for
            each non-goodwill asset in the CGU.

    Returns:
        ValuationResult with total impairment, allocation details, and
            post-impairment carrying values.

    Raises:
        ValueError: If inputs are invalid.

    Example:
        >>> result = cash_generating_unit_impairment(
        ...     cgu_carrying_value=100_000_000,
        ...     cgu_recoverable_amount=80_000_000,
        ...     goodwill_allocated=15_000_000,
        ...     other_assets=[
        ...         {"name": "Patents", "carrying_value": 40_000_000},
        ...         {"name": "Equipment", "carrying_value": 45_000_000},
        ...     ],
        ... )
        >>> result.value  # Total impairment
        20000000.0

    Reference:
        IAS 36.104-108: Impairment loss allocation to a CGU.
    """
    if not other_assets:
        raise ValueError("other_assets list cannot be empty")

    for i, asset in enumerate(other_assets):
        if "name" not in asset:
            raise ValueError(f"Asset {i} missing 'name' key")
        if "carrying_value" not in asset:
            raise ValueError(f"Asset {i} missing 'carrying_value' key")
        if asset["carrying_value"] < 0:
            raise ValueError(f"Asset {i} carrying_value must be non-negative")

    inputs = CGUImpairmentInputs(
        cgu_carrying_value=cgu_carrying_value,
        cgu_recoverable_amount=cgu_recoverable_amount,
        goodwill_allocated=goodwill_allocated,
        other_assets=other_assets,
    )

    steps: list[dict] = []

    total_impairment = max(0, inputs.cgu_carrying_value - inputs.cgu_recoverable_amount)
    remaining_impairment = total_impairment

    steps.append({"step": 1, "description": "IAS 36 CGU Impairment Allocation"})
    steps.append({
        "step": 2, "description": "CGU Carrying Value",
        "value": inputs.cgu_carrying_value,
    })
    steps.append({
        "step": 3, "description": "CGU Recoverable Amount",
        "value": inputs.cgu_recoverable_amount,
    })
    steps.append({
        "step": 4, "description": "Total Impairment Loss",
        "value": round(total_impairment, 2),
    })

    allocation_details: list[dict] = []
    if total_impairment == 0:
        steps.append({
            "step": 5,
            "description": "No impairment - recoverable amount "
                           "exceeds carrying value",
        })
    else:

        # Step 1: Allocate to goodwill first
        gw_impairment = min(remaining_impairment, inputs.goodwill_allocated)
        remaining_impairment -= gw_impairment
        remaining_goodwill = inputs.goodwill_allocated - gw_impairment

        allocation_details.append({
            "asset": "Goodwill",
            "carrying_value": inputs.goodwill_allocated,
            "impairment": round(gw_impairment, 2),
            "post_impairment": round(remaining_goodwill, 2),
        })
        steps.append({
            "step": 5,
            "description": "Allocate to goodwill",
            "impairment": round(gw_impairment, 2),
            "remaining": round(remaining_goodwill, 2),
        })

        # Step 2: Allocate remaining to other assets pro rata
        total_other_cv = sum(a["carrying_value"] for a in inputs.other_assets)
        for asset in inputs.other_assets:
            if total_other_cv > 0 and remaining_impairment > 0:
                proportion = asset["carrying_value"] / total_other_cv
                asset_impairment = remaining_impairment * proportion
            else:
                asset_impairment = 0.0

            post_impairment = asset["carrying_value"] - asset_impairment
            allocation_details.append({
                "asset": asset["name"],
                "carrying_value": asset["carrying_value"],
                "impairment": round(asset_impairment, 2),
                "post_impairment": round(post_impairment, 2),
            })
            steps.append({
                "step": len(steps) + 1,
                "description": f"Allocate to {asset['name']}",
                "proportion": f"{proportion:.1%}" if total_other_cv > 0 else "0%",
                "impairment": round(asset_impairment, 2),
                "post_impairment": round(post_impairment, 2),
            })

    return ValuationResult(
        value=round(total_impairment, 2),
        method="CGU Impairment Allocation (IAS 36)",
        formula_reference="IAS 36.104-108: Goodwill first, then pro rata",
        steps=steps,
        assumptions={
            "cgu_carrying_value": inputs.cgu_carrying_value,
            "cgu_recoverable_amount": inputs.cgu_recoverable_amount,
            "goodwill_allocated": inputs.goodwill_allocated,
            "total_impairment": round(total_impairment, 2),
            "allocation": allocation_details,
        },
    )

fair_value_less_costs_to_sell(fair_value: float, disposal_costs: float) -> ValuationResult

Calculate fair value less costs to sell per IAS 36.

Fair value less costs to sell (FVLCTS) is the amount obtainable from the sale of an asset in an arm's length transaction, less costs of disposal.

Formula

FVLCTS = Fair Value - Costs to Sell

Parameters:

Name Type Description Default
fair_value float

Fair value of the asset in an arm's length transaction.

required
disposal_costs float

Incremental costs directly attributable to disposal (legal costs, stamp duty, transaction taxes, removal costs).

required

Returns:

Type Description
ValuationResult

ValuationResult with FVLCTS amount, steps, and assumptions.

Raises:

Type Description
ValueError

If inputs are invalid.

Example

result = fair_value_less_costs_to_sell(10_000_000, 500_000) result.value 9500000.0

Reference

IAS 36.6-7: Definition of fair value less costs of disposal. IFRS 13: Fair Value Measurement.

Source code in src/advanced/impairment_testing.py
def fair_value_less_costs_to_sell(
    fair_value: float,
    disposal_costs: float,
) -> ValuationResult:
    """Calculate fair value less costs to sell per IAS 36.

    Fair value less costs to sell (FVLCTS) is the amount obtainable from
    the sale of an asset in an arm's length transaction, less costs of disposal.

    Formula:
        FVLCTS = Fair Value - Costs to Sell

    Args:
        fair_value: Fair value of the asset in an arm's length transaction.
        disposal_costs: Incremental costs directly attributable to disposal
            (legal costs, stamp duty, transaction taxes, removal costs).

    Returns:
        ValuationResult with FVLCTS amount, steps, and assumptions.

    Raises:
        ValueError: If inputs are invalid.

    Example:
        >>> result = fair_value_less_costs_to_sell(10_000_000, 500_000)
        >>> result.value
        9500000.0

    Reference:
        IAS 36.6-7: Definition of fair value less costs of disposal.
        IFRS 13: Fair Value Measurement.
    """
    inputs = FVLCTSInputs(fair_value=fair_value, disposal_costs=disposal_costs)

    fvlcts = inputs.fair_value - inputs.disposal_costs

    steps: list[dict] = [
        {"step": 1, "description": "IAS 36 Fair Value Less Costs to Sell"},
        {"step": 2, "description": "Fair Value", "value": inputs.fair_value},
        {"step": 3, "description": "Costs to Sell", "value": inputs.disposal_costs},
        {"step": 4, "description": "FVLCTS = Fair Value - Costs to Sell",
         "calculation": f"{inputs.fair_value} - {inputs.disposal_costs}"},
        {"step": 5, "description": "FVLCTS", "value": round(fvlcts, 2)},
    ]

    return ValuationResult(
        value=round(fvlcts, 2),
        method="Fair Value Less Costs to Sell (IAS 36)",
        formula_reference="IAS 36.6, FVLCTS = FV - Costs to Sell",
        steps=steps,
        assumptions={
            "fair_value": inputs.fair_value,
            "disposal_costs": inputs.disposal_costs,
        },
    )

goodwill_impairment_test(carrying_value: float, fair_value: float, reporting_unit: str = '', standard: str = 'ASC350') -> ValuationResult

Test goodwill for impairment per ASC 350 or IAS 36.

ASC 350 (US GAAP): Impairment = Carrying Value - Fair Value (if FV < CV, else 0) Single-step quantitative test.

IAS 36 (IFRS): Impairment = Carrying Value - Recoverable Amount Recoverable amount = higher of (FV less costs to sell, value in use) For goodwill, the reporting unit is the cash-generating unit (CGU).

Parameters:

Name Type Description Default
carrying_value float

Carrying value of the reporting unit (including goodwill).

required
fair_value float

Fair value of the reporting unit.

required
reporting_unit str

Name of the reporting unit being tested.

''
standard str

"ASC350" for US GAAP, "IAS36" for IFRS.

'ASC350'

Returns:

Type Description
ValuationResult

ValuationResult with impairment amount (0 if no impairment).

Raises:

Type Description
ValueError

If inputs are invalid.

Example

result = goodwill_impairment_test(50_000_000, 40_000_000, "Tech Division") result.value 10000000.0

Source code in src/advanced/impairment_testing.py
def goodwill_impairment_test(
    carrying_value: float,
    fair_value: float,
    reporting_unit: str = "",
    standard: str = "ASC350",
) -> ValuationResult:
    """Test goodwill for impairment per ASC 350 or IAS 36.

    ASC 350 (US GAAP):
        Impairment = Carrying Value - Fair Value (if FV < CV, else 0)
        Single-step quantitative test.

    IAS 36 (IFRS):
        Impairment = Carrying Value - Recoverable Amount
        Recoverable amount = higher of (FV less costs to sell, value in use)
        For goodwill, the reporting unit is the cash-generating unit (CGU).

    Args:
        carrying_value: Carrying value of the reporting unit (including goodwill).
        fair_value: Fair value of the reporting unit.
        reporting_unit: Name of the reporting unit being tested.
        standard: "ASC350" for US GAAP, "IAS36" for IFRS.

    Returns:
        ValuationResult with impairment amount (0 if no impairment).

    Raises:
        ValueError: If inputs are invalid.

    Example:
        >>> result = goodwill_impairment_test(50_000_000, 40_000_000, "Tech Division")
        >>> result.value
        10000000.0
    """
    GoodwillImpairmentInput(
        carrying_value=carrying_value,
        fair_value=fair_value,
        reporting_unit=reporting_unit,
        standard=standard,
    )

    if standard == "ASC350":
        if fair_value < carrying_value:
            impairment = carrying_value - fair_value
            impaired = True
        else:
            impairment = 0.0
            impaired = False

        steps = [
            {
                "step": 1,
                "description": "Standard: ASC 350 (US GAAP) - "
                               "One-step goodwill impairment test",
            },
            {"step": 2, "description": f"Reporting Unit: {reporting_unit or 'N/A'}"},
            {"step": 3, "description": "Carrying Value", "value": carrying_value},
            {"step": 4, "description": "Fair Value", "value": fair_value},
            {
                "step": 5,
                "description": "Impairment = max(0, CV - FV)",
                "calculation": f"max(0, {carrying_value} - {fair_value})",
            },
            {"step": 6, "description": "Impairment Loss", "value": round(impairment, 2)},
        ]
        formula_ref = "Ch 10.4, ASC 350-20-35"

    elif standard == "IAS36":
        recoverable_amount = fair_value
        if recoverable_amount < carrying_value:
            impairment = carrying_value - recoverable_amount
            impaired = True
        else:
            impairment = 0.0
            impaired = False

        steps = [
            {
                "step": 1,
                "description": "Standard: IAS 36 (IFRS) - Impairment of Assets",
            },
            {"step": 2, "description": f"Reporting Unit (CGU): {reporting_unit or 'N/A'}"},
            {"step": 3, "description": "Carrying Value", "value": carrying_value},
            {
                "step": 4,
                "description": "Recoverable Amount (using fair value)",
                "value": recoverable_amount,
            },
            {
                "step": 5,
                "description": "Impairment = max(0, CV - Recoverable Amount)",
                "calculation": f"max(0, {carrying_value} - {recoverable_amount})",
            },
            {"step": 6, "description": "Impairment Loss", "value": round(impairment, 2)},
        ]
        formula_ref = "Ch 10.4, IAS 36"
    else:
        raise ValueError(f"Unsupported standard: {standard}. Use 'ASC350' or 'IAS36'.")

    return ValuationResult(
        value=round(impairment, 2),
        method=f"Goodwill Impairment Test ({standard})",
        formula_reference=formula_ref,
        steps=steps,
        assumptions={
            "carrying_value": carrying_value,
            "fair_value": fair_value,
            "reporting_unit": reporting_unit,
            "standard": standard,
            "impaired": impaired,
        },
    )

intangible_impairment_test(carrying_value: float, fair_value: float | None = None, recoverable_amount: float | None = None, standard: str = 'ASC350') -> ValuationResult

Test intangible asset for impairment per ASC 350 or IAS 36.

ASC 350 (US GAAP): For indefinite-lived intangibles: Compare carrying value to fair value. Impairment = CV - FV (if FV < CV).

IAS 36 (IFRS): Compare carrying value to recoverable amount. Recoverable amount = higher of (FV less costs to sell, value in use). Impairment = CV - Recoverable Amount (if RA < CV).

Parameters:

Name Type Description Default
carrying_value float

Carrying value of the intangible asset.

required
fair_value float | None

Fair value of the asset (required for ASC350).

None
recoverable_amount float | None

Recoverable amount (required for IAS36).

None
standard str

"ASC350" for US GAAP, "IAS36" for IFRS.

'ASC350'

Returns:

Type Description
ValuationResult

ValuationResult with impairment amount (0 if no impairment).

Raises:

Type Description
ValueError

If required parameters are missing for the chosen standard.

Example

result = intangible_impairment_test(20_000_000, fair_value=15_000_000) result.value 5000000.0

Source code in src/advanced/impairment_testing.py
def intangible_impairment_test(
    carrying_value: float,
    fair_value: float | None = None,
    recoverable_amount: float | None = None,
    standard: str = "ASC350",
) -> ValuationResult:
    """Test intangible asset for impairment per ASC 350 or IAS 36.

    ASC 350 (US GAAP):
        For indefinite-lived intangibles: Compare carrying value to fair value.
        Impairment = CV - FV (if FV < CV).

    IAS 36 (IFRS):
        Compare carrying value to recoverable amount.
        Recoverable amount = higher of (FV less costs to sell, value in use).
        Impairment = CV - Recoverable Amount (if RA < CV).

    Args:
        carrying_value: Carrying value of the intangible asset.
        fair_value: Fair value of the asset (required for ASC350).
        recoverable_amount: Recoverable amount (required for IAS36).
        standard: "ASC350" for US GAAP, "IAS36" for IFRS.

    Returns:
        ValuationResult with impairment amount (0 if no impairment).

    Raises:
        ValueError: If required parameters are missing for the chosen standard.

    Example:
        >>> result = intangible_impairment_test(20_000_000, fair_value=15_000_000)
        >>> result.value
        5000000.0
    """
    IntangibleImpairmentInput(
        carrying_value=carrying_value,
        fair_value=fair_value,
        recoverable_amount=recoverable_amount,
        standard=standard,
    )

    if standard == "ASC350":
        if fair_value is None:
            raise ValueError("fair_value is required for ASC350 impairment test")

        if fair_value < carrying_value:
            impairment = carrying_value - fair_value
            impaired = True
        else:
            impairment = 0.0
            impaired = False

        steps = [
            {
                "step": 1,
                "description": "Standard: ASC 350 - "
                               "Indefinite-lived intangible impairment test",
            },
            {"step": 2, "description": "Carrying Value", "value": carrying_value},
            {"step": 3, "description": "Fair Value", "value": fair_value},
            {
                "step": 4,
                "description": "Impairment = max(0, CV - FV)",
                "calculation": f"max(0, {carrying_value} - {fair_value})",
            },
            {"step": 5, "description": "Impairment Loss", "value": round(impairment, 2)},
        ]
        formula_ref = "Ch 10.4, ASC 350-30-35"

    elif standard == "IAS36":
        if recoverable_amount is None:
            raise ValueError("recoverable_amount is required for IAS36 impairment test")

        if recoverable_amount < carrying_value:
            impairment = carrying_value - recoverable_amount
            impaired = True
        else:
            impairment = 0.0
            impaired = False

        steps = [
            {
                "step": 1,
                "description": "Standard: IAS 36 - Impairment of Assets",
            },
            {"step": 2, "description": "Carrying Value", "value": carrying_value},
            {"step": 3, "description": "Recoverable Amount", "value": recoverable_amount},
            {
                "step": 4,
                "description": "Impairment = max(0, CV - Recoverable Amount)",
                "calculation": f"max(0, {carrying_value} - {recoverable_amount})",
            },
            {"step": 5, "description": "Impairment Loss", "value": round(impairment, 2)},
        ]
        formula_ref = "Ch 10.4, IAS 36"
    else:
        raise ValueError(f"Unsupported standard: {standard}. Use 'ASC350' or 'IAS36'.")

    return ValuationResult(
        value=round(impairment, 2),
        method=f"Intangible Impairment Test ({standard})",
        formula_reference=formula_ref,
        steps=steps,
        assumptions={
            "carrying_value": carrying_value,
            "fair_value": fair_value,
            "recoverable_amount": recoverable_amount,
            "standard": standard,
            "impaired": impaired,
        },
    )

value_in_use(cash_flow_projections: list[float], terminal_growth_rate: float, discount_rate: float) -> ValuationResult

Calculate value in use per IAS 36 using discounted cash flows.

Value in use is the present value of future cash flows expected to be derived from an asset or cash-generating unit, including a terminal value.

Formula

VIU = sum(CF_t / (1+r)^t) + Terminal Value / (1+r)^n Terminal Value = CF_n x (1+g) / (r - g)

Where

CF_t = cash flow in period t r = pre-tax discount rate g = terminal growth rate n = number of explicit projection periods

Parameters:

Name Type Description Default
cash_flow_projections list[float]

Projected future cash flows (must be non-negative).

required
terminal_growth_rate float

Perpetual growth rate for terminal value (decimal).

required
discount_rate float

Pre-tax discount rate reflecting current market assessment.

required

Returns:

Type Description
ValuationResult

ValuationResult with value in use, calculation steps, and assumptions.

Raises:

Type Description
ValueError

If inputs are invalid (e.g., discount_rate <= terminal_growth_rate).

Example

result = value_in_use( ... cash_flow_projections=[5_000_000, 5_500_000, 6_000_000], ... terminal_growth_rate=0.02, ... discount_rate=0.10, ... ) result.value > 0 True

Reference

IAS 36.57-59: Value in use calculation requirements.

Source code in src/advanced/impairment_testing.py
def value_in_use(
    cash_flow_projections: list[float],
    terminal_growth_rate: float,
    discount_rate: float,
) -> ValuationResult:
    """Calculate value in use per IAS 36 using discounted cash flows.

    Value in use is the present value of future cash flows expected to be
    derived from an asset or cash-generating unit, including a terminal value.

    Formula:
        VIU = sum(CF_t / (1+r)^t) + Terminal Value / (1+r)^n
        Terminal Value = CF_n x (1+g) / (r - g)

    Where:
        CF_t = cash flow in period t
        r = pre-tax discount rate
        g = terminal growth rate
        n = number of explicit projection periods

    Args:
        cash_flow_projections: Projected future cash flows (must be non-negative).
        terminal_growth_rate: Perpetual growth rate for terminal value (decimal).
        discount_rate: Pre-tax discount rate reflecting current market assessment.

    Returns:
        ValuationResult with value in use, calculation steps, and assumptions.

    Raises:
        ValueError: If inputs are invalid (e.g., discount_rate <= terminal_growth_rate).

    Example:
        >>> result = value_in_use(
        ...     cash_flow_projections=[5_000_000, 5_500_000, 6_000_000],
        ...     terminal_growth_rate=0.02,
        ...     discount_rate=0.10,
        ... )
        >>> result.value > 0
        True

    Reference:
        IAS 36.57-59: Value in use calculation requirements.
    """
    inputs = ValueInUseInputs(
        cash_flow_projections=cash_flow_projections,
        terminal_growth_rate=terminal_growth_rate,
        discount_rate=discount_rate,
    )

    if inputs.discount_rate <= inputs.terminal_growth_rate:
        raise ValueError(
            "Discount rate must exceed terminal growth rate. "
            f"Got discount_rate={inputs.discount_rate}, "
            f"terminal_growth_rate={inputs.terminal_growth_rate}"
        )

    steps: list[dict] = []
    pv_explicit = 0.0
    n = len(inputs.cash_flow_projections)

    steps.append({"step": 1, "description": "IAS 36 Value in Use Calculation"})
    steps.append({"step": 2, "description": f"Projection periods: {n}"})
    steps.append({"step": 3, "description": f"Discount rate: {inputs.discount_rate:.2%}"})
    steps.append({"step": 4, "description": f"Terminal growth rate: {inputs.terminal_growth_rate:.2%}"})

    for t, cf in enumerate(inputs.cash_flow_projections, start=1):
        pv = present_value(cf, inputs.discount_rate, t)
        pv_explicit += pv
        steps.append({
            "step": t + 4,
            "description": f"Year {t} cash flow",
            "value": cf,
            "pv": round(pv, 2),
        })

    # Terminal value using Gordon Growth Model
    final_cf = inputs.cash_flow_projections[-1]
    terminal_val = terminal_value(final_cf, inputs.terminal_growth_rate, inputs.discount_rate)
    pv_terminal = present_value(terminal_val, inputs.discount_rate, n)

    calc = f"{final_cf} x (1+{inputs.terminal_growth_rate}) / ({inputs.discount_rate} - {inputs.terminal_growth_rate})"
    steps.append({
        "step": n + 5,
        "description": "Terminal Value (Gordon Growth)",
        "calculation": calc,
        "value": round(terminal_val, 2),
        "pv": round(pv_terminal, 2),
    })

    viu = pv_explicit + pv_terminal

    steps.append({
        "step": n + 6,
        "description": "Value in Use",
        "calculation": f"PV(explicit) + PV(terminal) = {pv_explicit:,.0f} + {pv_terminal:,.0f}",
        "value": round(viu, 2),
    })

    return ValuationResult(
        value=round(viu, 2),
        method="IAS 36 Value in Use",
        formula_reference="IAS 36.30, VIU = sum(CF_t/(1+r)^t) + TV/(1+r)^n",
        steps=steps,
        assumptions={
            "cash_flow_projections": inputs.cash_flow_projections,
            "terminal_growth_rate": inputs.terminal_growth_rate,
            "discount_rate": inputs.discount_rate,
            "pv_explicit_cash_flows": round(pv_explicit, 2),
            "terminal_value": round(terminal_val, 2),
            "pv_terminal_value": round(pv_terminal, 2),
        },
    )

Transfer Pricing

transfer_pricing

Transfer pricing calculations.

Implements Section 16.1 and 16.3: - Currency-adjusted discount rate (re-exports from core.discount_rates) - CUP (Comparable Uncontrolled Price) transfer price analysis

Classes

Functions

cup_transfer_price(controlled_price: float, uncontrolled_prices: list[float]) -> ValuationResult

Calculate arm's length range using the Comparable Uncontrolled Price (CUP) method.

The CUP method compares the price charged in a controlled transaction to the price charged in comparable uncontrolled transactions. Returns the interquartile range (IQR) of uncontrolled prices as the arm's length range, per OECD Transfer Pricing Guidelines.

Parameters:

Name Type Description Default
controlled_price float

Price charged in the controlled (related-party) transaction.

required
uncontrolled_prices list[float]

List of prices from comparable uncontrolled transactions.

required

Returns:

Type Description
ValuationResult

ValuationResult with arm's length range, controlled price assessment,

ValuationResult

and statistical analysis of comparables.

Raises:

Type Description
ValueError

If controlled_price <= 0 or fewer than 3 uncontrolled prices.

Example

result = cup_transfer_price(100, [90, 95, 100, 105, 110]) result.assumptions["arms_length_range"] (95.0, 105.0)

Source code in src/advanced/transfer_pricing.py
def cup_transfer_price(
    controlled_price: float,
    uncontrolled_prices: list[float],
) -> ValuationResult:
    """Calculate arm's length range using the Comparable Uncontrolled Price (CUP) method.

    The CUP method compares the price charged in a controlled transaction to the price
    charged in comparable uncontrolled transactions. Returns the interquartile range (IQR)
    of uncontrolled prices as the arm's length range, per OECD Transfer Pricing Guidelines.

    Args:
        controlled_price: Price charged in the controlled (related-party) transaction.
        uncontrolled_prices: List of prices from comparable uncontrolled transactions.

    Returns:
        ValuationResult with arm's length range, controlled price assessment,
        and statistical analysis of comparables.

    Raises:
        ValueError: If controlled_price <= 0 or fewer than 3 uncontrolled prices.

    Example:
        >>> result = cup_transfer_price(100, [90, 95, 100, 105, 110])
        >>> result.assumptions["arms_length_range"]
        (95.0, 105.0)
    """
    if len(uncontrolled_prices) < 3:
        raise ValueError("At least 3 uncontrolled prices are required for CUP analysis")

    CUPInput(controlled_price=controlled_price, uncontrolled_prices=uncontrolled_prices)

    for p in uncontrolled_prices:
        if p <= 0:
            raise ValueError("All uncontrolled prices must be > 0")

    sorted_prices = sorted(uncontrolled_prices)
    q1 = float(np.percentile(sorted_prices, 25))
    q3 = float(np.percentile(sorted_prices, 75))
    median = float(np.median(sorted_prices))
    mean = float(np.mean(sorted_prices))
    iqr = q3 - q1

    within_range = q1 <= controlled_price <= q3

    steps = [
        {"step": 1, "description": "Collect comparable uncontrolled prices", "value": len(uncontrolled_prices)},
        {"step": 2, "description": "Sort and compute quartiles"},
        {"step": 3, "description": "Q1 (25th percentile)", "value": round(q1, 2)},
        {"step": 4, "description": "Median (50th percentile)", "value": round(median, 2)},
        {"step": 5, "description": "Q3 (75th percentile)", "value": round(q3, 2)},
        {"step": 6, "description": "Interquartile Range (IQR)", "value": round(iqr, 2)},
        {"step": 7, "description": "Controlled Price", "value": controlled_price},
        {"step": 8, "description": "Within arm's length range", "value": within_range},
    ]

    return ValuationResult(
        value=round(median, 2),
        method="Comparable Uncontrolled Price (CUP)",
        formula_reference="Ch 16.1, OECD TP Guidelines",
        steps=steps,
        assumptions={
            "controlled_price": controlled_price,
            "uncontrolled_prices": sorted_prices,
            "arms_length_range": (round(q1, 2), round(q3, 2)),
            "median": round(median, 2),
            "mean": round(mean, 2),
            "iqr": round(iqr, 2),
            "within_range": within_range,
        },
    )

Utility Functions

formulas

Utility functions for valuation formulas, sensitivity analysis, and contributory asset charges.

Implements useful life estimation, sensitivity analysis, and contributory asset charge calculations from Appendix A and Chapter 5 of the Ascent Partners textbook.

All functions return structured dicts with
  • value: The computed result
  • method: The calculation method used
  • formula_reference: Reference to the methodology
  • steps: Step-by-step calculation breakdown
  • assumptions: List of assumptions made during calculation

Functions

estimate_useful_life(asset_type: str, legal_life: float | None = None, economic_factors: dict[str, float] | None = None, obsolescence_rate: float = 0.05) -> ValuationResult

Estimate the useful life of an intangible asset.

The useful life is the shorter of: 1. Legal life (if applicable) 2. Economic life (based on obsolescence and market factors)

Economic life is estimated using

Economic Life = -ln(threshold) / obsolescence_rate where threshold = 0.10 (value drops below 10% of original)

Parameters:

Name Type Description Default
asset_type str

Type of intangible asset (e.g., "patent", "trademark", "software")

required
legal_life float | None

Legal protection period in years (overrides default for asset type)

None
economic_factors dict[str, float] | None

Optional dict of economic adjustment factors - "market_growth": Market growth rate adjustment - "competition": Competitive pressure factor (0-1) - "tech_change": Rate of technological change

None
obsolescence_rate float

Annual obsolescence rate (default 0.05)

0.05

Returns:

Type Description
ValuationResult

ValuationResult with estimated useful life in years

Raises:

Type Description
ValueError

If asset_type is unknown or parameters are invalid

Book Reference

Appendix A, Section A.2 — Useful Life Estimation Chapter 5, Multi-Period Excess Earnings Method — Projection Period

Source code in src/utils/formulas.py
def estimate_useful_life(
    asset_type: str,
    legal_life: float | None = None,
    economic_factors: dict[str, float] | None = None,
    obsolescence_rate: float = 0.05,
) -> ValuationResult:
    """Estimate the useful life of an intangible asset.

    The useful life is the shorter of:
    1. Legal life (if applicable)
    2. Economic life (based on obsolescence and market factors)

    Economic life is estimated using:
        Economic Life = -ln(threshold) / obsolescence_rate
        where threshold = 0.10 (value drops below 10% of original)

    Parameters:
        asset_type: Type of intangible asset (e.g., "patent", "trademark", "software")
        legal_life: Legal protection period in years (overrides default for asset type)
        economic_factors: Optional dict of economic adjustment factors
            - "market_growth": Market growth rate adjustment
            - "competition": Competitive pressure factor (0-1)
            - "tech_change": Rate of technological change
        obsolescence_rate: Annual obsolescence rate (default 0.05)

    Returns:
        ValuationResult with estimated useful life in years

    Raises:
        ValueError: If asset_type is unknown or parameters are invalid

    Book Reference:
        Appendix A, Section A.2 — Useful Life Estimation
        Chapter 5, Multi-Period Excess Earnings Method — Projection Period
    """
    UsefulLifeInput(
        asset_type=asset_type,
        legal_life=legal_life,
        obsolescence_rate=obsolescence_rate,
    )

    asset_type_lower = asset_type.lower()
    defaults = DEFAULT_USEFUL_LIVES.get(asset_type_lower)

    if defaults is None and legal_life is None:
        raise ValueError(
            f"Unknown asset type '{asset_type}'. "
            f"Provide legal_life or use one of: {', '.join(DEFAULT_USEFUL_LIVES.keys())}"
        )

    effective_obsolescence = obsolescence_rate

    if economic_factors:
        competition = economic_factors.get("competition", 0)
        tech_change = economic_factors.get("tech_change", 0)
        effective_obsolescence = obsolescence_rate + competition * 0.02 + tech_change * 0.03
        effective_obsolescence = min(effective_obsolescence, 0.50)

    threshold = 0.10
    if math.isclose(effective_obsolescence, 0.0, abs_tol=1e-12):
        economic_life = float("inf")
    else:
        economic_life = -math.log(threshold) / effective_obsolescence

    if legal_life is not None and legal_life > 0:
        useful_life = min(economic_life, legal_life)
    elif defaults and defaults["legal_max"] != float("inf") and defaults["legal_max"] is not None:
        useful_life = min(economic_life, defaults["legal_max"])
    else:
        useful_life = economic_life

    if math.isfinite(useful_life):
        useful_life = round(useful_life, 1)

    steps = [
        f"Asset Type: {asset_type}",
        f"Obsolescence Rate: {effective_obsolescence:.2%}",
        f"Economic Life (10% threshold): {economic_life:.1f} years",
    ]

    if legal_life is not None:
        steps.append(f"Legal Life: {legal_life} years")
        steps.append(f"Useful Life = min({economic_life:.1f}, {legal_life}) = {useful_life} years")
    else:
        steps.append(f"Useful Life: {useful_life} years (economic life, no legal limit)")

    return ValuationResult(
        value=useful_life,
        method="Useful Life Estimation",
        formula_reference="Economic Life = -ln(0.10) / obsolescence_rate; Useful Life = min(legal, economic)",
        steps=steps,
        assumptions=[
            f"Asset type: {asset_type}",
            f"Value threshold for economic life: {threshold:.0%} of original value",
            "Obsolescence compounds annually",
            "No legal renewal or extension assumed",
        ],
    )

sensitivity_analysis(function_name: str, parameter_name: str, parameter_range: list[float], fixed_parameters: dict[str, Any]) -> dict[str, Any]

Perform sensitivity analysis on a valuation function.

Evaluates the function across a range of values for one parameter while holding all others constant.

Parameters:

Name Type Description Default
function_name str

Name of the function to analyze. Supported: - "present_value" - "future_value" - "annuity_pv" - "perpetuity_pv" - "growing_annuity_pv" - "terminal_value" - "build_up_discount_rate" - "capm_discount_rate" - "wacc"

required
parameter_name str

The parameter to vary

required
parameter_range list[float]

List of values to test for the parameter

required
fixed_parameters dict[str, Any]

Dict of fixed parameter values for all other parameters

required

Returns:

Type Description
dict[str, Any]

Dict with keys: - function_name: Name of the analyzed function - parameter_name: Name of the varied parameter - results: List of {"parameter_value": float, "result": float} - min_result: Minimum result value - max_result: Maximum result value - sensitivity_range: max_result - min_result - method: "Sensitivity Analysis" - formula_reference: Reference description - steps: Description of the analysis - assumptions: List of assumptions

Raises:

Type Description
ValueError

If function_name is not supported or parameter_range is empty

Book Reference

Appendix A, Section A.3 — Sensitivity Analysis Used to assess how valuation changes with key input assumptions

Source code in src/utils/formulas.py
def sensitivity_analysis(
    function_name: str,
    parameter_name: str,
    parameter_range: list[float],
    fixed_parameters: dict[str, Any],
) -> dict[str, Any]:
    """Perform sensitivity analysis on a valuation function.

    Evaluates the function across a range of values for one parameter
    while holding all others constant.

    Parameters:
        function_name: Name of the function to analyze. Supported:
            - "present_value"
            - "future_value"
            - "annuity_pv"
            - "perpetuity_pv"
            - "growing_annuity_pv"
            - "terminal_value"
            - "build_up_discount_rate"
            - "capm_discount_rate"
            - "wacc"
        parameter_name: The parameter to vary
        parameter_range: List of values to test for the parameter
        fixed_parameters: Dict of fixed parameter values for all other parameters

    Returns:
        Dict with keys:
            - function_name: Name of the analyzed function
            - parameter_name: Name of the varied parameter
            - results: List of {"parameter_value": float, "result": float}
            - min_result: Minimum result value
            - max_result: Maximum result value
            - sensitivity_range: max_result - min_result
            - method: "Sensitivity Analysis"
            - formula_reference: Reference description
            - steps: Description of the analysis
            - assumptions: List of assumptions

    Raises:
        ValueError: If function_name is not supported or parameter_range is empty

    Book Reference:
        Appendix A, Section A.3 — Sensitivity Analysis
        Used to assess how valuation changes with key input assumptions
    """
    if not parameter_range:
        raise ValueError("parameter_range must contain at least one value")

    function_map: dict[str, Callable] = {
        "present_value": _call_present_value,
        "future_value": _call_future_value,
        "annuity_pv": _call_annuity_pv,
        "perpetuity_pv": _call_perpetuity_pv,
        "growing_annuity_pv": _call_growing_annuity_pv,
        "terminal_value": _call_terminal_value,
        "build_up_discount_rate": _call_build_up,
        "capm_discount_rate": _call_capm,
        "wacc": _call_wacc,
    }

    if function_name not in function_map:
        raise ValueError(
            f"Unsupported function: {function_name}. "
            f"Supported: {', '.join(function_map.keys())}"
        )

    func = function_map[function_name]
    results = []

    for param_value in parameter_range:
        params = {**fixed_parameters, parameter_name: param_value}
        try:
            result = func(**params)
            results.append({
                "parameter_value": param_value,
                "result": result,
            })
        except (ValueError, TypeError) as e:
            results.append({
                "parameter_value": param_value,
                "result": None,
                "error": str(e),
            })

    valid_results = [r["result"] for r in results if r["result"] is not None]

    if valid_results:
        min_result = min(valid_results)
        max_result = max(valid_results)
        sensitivity_range = max_result - min_result
    else:
        min_result = None
        max_result = None
        sensitivity_range = None

    return {
        "function_name": function_name,
        "parameter_name": parameter_name,
        "results": results,
        "min_result": min_result,
        "max_result": max_result,
        "sensitivity_range": sensitivity_range,
        "method": "Sensitivity Analysis",
        "formula_reference": f"One-at-a-time sensitivity analysis on {parameter_name} for {function_name}",
        "steps": [
            f"Function: {function_name}",
            f"Parameter varied: {parameter_name}",
            f"Range: {parameter_range}",
            f"Fixed parameters: {list(fixed_parameters.keys())}",
            f"Results: {len(results)} evaluations",
            f"Output range: {min_result} to {max_result}" if valid_results else "No valid results",
        ],
        "assumptions": [
            "All other parameters held constant",
            "Parameter values are within valid ranges for the function",
            "Linear interpolation between tested points is reasonable",
        ],
    }

contributory_asset_charges(assets: list[dict[str, Any]]) -> dict[str, Any]

Calculate contributory asset charges (CAC) for a set of supporting assets.

Contributory asset charges represent the return required on supporting assets (working capital, fixed assets, assembled workforce, etc.) that contribute to the earnings of the subject intangible asset.

Formula

CAC_i = Asset_Value_i * Return_Rate_i Total CAC = sum(CAC_i)

Parameters:

Name Type Description Default
assets list[dict[str, Any]]

List of dicts with keys: - type: Asset type (e.g., "working_capital", "fixed_assets", "assembled_workforce") - value: Asset value - return_rate: Required return rate for that asset type

required

Returns:

Type Description
dict[str, Any]

Dict with keys: - total_cac: Sum of all contributory asset charges - asset_charges: List of {"type": str, "value": float, "return_rate": float, "charge": float} - method: "Contributory Asset Charge" - formula_reference: Reference description - steps: Step-by-step calculation - assumptions: List of assumptions

Raises:

Type Description
ValueError

If assets list is empty or values are invalid

Book Reference

Chapter 5, Section 5.3 — Contributory Asset Charges Used in Multi-Period Excess Earnings Method (MPEEM) to isolate cash flows attributable to the subject intangible asset

Source code in src/utils/formulas.py
def contributory_asset_charges(
    assets: list[dict[str, Any]],
) -> dict[str, Any]:
    """Calculate contributory asset charges (CAC) for a set of supporting assets.

    Contributory asset charges represent the return required on supporting assets
    (working capital, fixed assets, assembled workforce, etc.) that contribute
    to the earnings of the subject intangible asset.

    Formula:
        CAC_i = Asset_Value_i * Return_Rate_i
        Total CAC = sum(CAC_i)

    Parameters:
        assets: List of dicts with keys:
            - type: Asset type (e.g., "working_capital", "fixed_assets", "assembled_workforce")
            - value: Asset value
            - return_rate: Required return rate for that asset type

    Returns:
        Dict with keys:
            - total_cac: Sum of all contributory asset charges
            - asset_charges: List of {"type": str, "value": float, "return_rate": float, "charge": float}
            - method: "Contributory Asset Charge"
            - formula_reference: Reference description
            - steps: Step-by-step calculation
            - assumptions: List of assumptions

    Raises:
        ValueError: If assets list is empty or values are invalid

    Book Reference:
        Chapter 5, Section 5.3 — Contributory Asset Charges
        Used in Multi-Period Excess Earnings Method (MPEEM) to isolate cash flows
        attributable to the subject intangible asset
    """
    if not assets:
        raise ValueError("assets list must contain at least one asset")

    validated_assets = [ContributoryAssetInput(**a) for a in assets]

    asset_charges = []
    total_cac = 0.0
    steps = []

    for asset in validated_assets:
        charge = asset.value * asset.return_rate
        total_cac += charge
        asset_charges.append({
            "type": asset.type,
            "value": asset.value,
            "return_rate": asset.return_rate,
            "charge": round(charge, 2),
        })
        steps.append(
            f"{asset.type}: ${asset.value:,.2f} * {asset.return_rate:.2%} = ${charge:,.2f}"
        )

    steps.append(f"Total CAC = ${total_cac:,.2f}")

    return {
        "total_cac": round(total_cac, 2),
        "asset_charges": asset_charges,
        "method": "Contributory Asset Charge",
        "formula_reference": "CAC_i = Asset_Value_i * Return_Rate_i; Total CAC = sum(CAC_i)",
        "steps": steps,
        "assumptions": [
            "Asset values represent fair market values",
            "Return rates reflect the risk of each asset class",
            "All contributory assets are necessary for the subject asset's earnings",
            "Charges represent opportunity cost of capital tied up in supporting assets",
        ],
    }