from __future__ import annotations """Pricing service implementing commodity revenue calculations. This module exposes data models and helpers for computing product pricing according to the formulas outlined in ``calminer-docs/specifications/price_calculation.md``. It focuses on the core calculation steps (payable metal, penalties, net revenue) and is intended to be composed within broader scenario evaluation workflows. """ from dataclasses import dataclass, field from typing import Mapping from pydantic import BaseModel, Field, PositiveFloat, field_validator from services.currency import require_currency class PricingInput(BaseModel): """Normalized inputs for pricing calculations.""" metal: str = Field(..., min_length=1) ore_tonnage: PositiveFloat = Field( ..., description="Total ore mass processed (metric tonnes)") head_grade_pct: PositiveFloat = Field(..., gt=0, le=100, description="Head grade as percent") recovery_pct: PositiveFloat = Field(..., gt=0, le=100, description="Recovery rate percent") payable_pct: float | None = Field( None, gt=0, le=100, description="Contractual payable percentage") reference_price: PositiveFloat = Field( ..., description="Reference price in base currency per unit") treatment_charge: float = Field(0, ge=0) smelting_charge: float = Field(0, ge=0) moisture_pct: float = Field(0, ge=0, le=100) moisture_threshold_pct: float | None = Field(None, ge=0, le=100) moisture_penalty_per_pct: float | None = Field(None) impurity_ppm: Mapping[str, float] = Field(default_factory=dict) impurity_thresholds: Mapping[str, float] = Field(default_factory=dict) impurity_penalty_per_ppm: Mapping[str, float] = Field(default_factory=dict) premiums: float = Field(0) fx_rate: PositiveFloat = Field( 1, description="Multiplier to convert to scenario currency") currency_code: str | None = Field( None, description="Optional explicit currency override") @field_validator("impurity_ppm", mode="before") @classmethod def _validate_impurity_mapping(cls, value): if isinstance(value, Mapping): return {k: float(v) for k, v in value.items()} return value class PricingResult(BaseModel): """Structured output summarising pricing computation results.""" metal: str ore_tonnage: float head_grade_pct: float recovery_pct: float payable_metal_tonnes: float reference_price: float gross_revenue: float moisture_penalty: float impurity_penalty: float treatment_smelt_charges: float premiums: float net_revenue: float currency: str | None @dataclass(frozen=True) class PricingMetadata: """Metadata defaults applied when explicit inputs are omitted.""" default_payable_pct: float = 100.0 default_currency: str | None = "USD" moisture_threshold_pct: float = 8.0 moisture_penalty_per_pct: float = 0.0 impurity_thresholds: Mapping[str, float] = field(default_factory=dict) impurity_penalty_per_ppm: Mapping[str, float] = field(default_factory=dict) def calculate_pricing( pricing_input: PricingInput, *, metadata: PricingMetadata | None = None, currency: str | None = None, ) -> PricingResult: """Calculate pricing metrics for the provided commodity input. Parameters ---------- pricing_input: Normalised input data including ore tonnage, grades, charges, and optional penalties. metadata: Optional default metadata applied when specific values are omitted from ``pricing_input``. currency: Optional override for the output currency label. Falls back to ``metadata.default_currency`` when not provided. """ applied_metadata = metadata or PricingMetadata() payable_pct = ( pricing_input.payable_pct if pricing_input.payable_pct is not None else applied_metadata.default_payable_pct ) moisture_threshold = ( pricing_input.moisture_threshold_pct if pricing_input.moisture_threshold_pct is not None else applied_metadata.moisture_threshold_pct ) moisture_penalty_factor = ( pricing_input.moisture_penalty_per_pct if pricing_input.moisture_penalty_per_pct is not None else applied_metadata.moisture_penalty_per_pct ) impurity_thresholds = { **applied_metadata.impurity_thresholds, **pricing_input.impurity_thresholds, } impurity_penalty_factors = { **applied_metadata.impurity_penalty_per_ppm, **pricing_input.impurity_penalty_per_ppm, } q_metal = pricing_input.ore_tonnage * (pricing_input.head_grade_pct / 100.0) * ( pricing_input.recovery_pct / 100.0 ) payable_metal = q_metal * (payable_pct / 100.0) gross_revenue_ref = payable_metal * pricing_input.reference_price charges = pricing_input.treatment_charge + pricing_input.smelting_charge moisture_excess = max(0.0, pricing_input.moisture_pct - moisture_threshold) moisture_penalty = moisture_excess * moisture_penalty_factor impurity_penalty_total = 0.0 for impurity, value in pricing_input.impurity_ppm.items(): threshold = impurity_thresholds.get(impurity, 0.0) penalty_factor = impurity_penalty_factors.get(impurity, 0.0) impurity_penalty_total += max(0.0, value - threshold) * penalty_factor net_revenue_ref = ( gross_revenue_ref - charges - moisture_penalty - impurity_penalty_total ) net_revenue_ref += pricing_input.premiums net_revenue = net_revenue_ref * pricing_input.fx_rate currency_code = require_currency( currency or pricing_input.currency_code, default=applied_metadata.default_currency, ) return PricingResult( metal=pricing_input.metal, ore_tonnage=pricing_input.ore_tonnage, head_grade_pct=pricing_input.head_grade_pct, recovery_pct=pricing_input.recovery_pct, payable_metal_tonnes=payable_metal, reference_price=pricing_input.reference_price, gross_revenue=gross_revenue_ref, moisture_penalty=moisture_penalty, impurity_penalty=impurity_penalty_total, treatment_smelt_charges=charges, premiums=pricing_input.premiums, net_revenue=net_revenue, currency=currency_code, )