- Introduced Pydantic schemas for profitability calculations in `schemas/calculations.py`. - Implemented service functions for profitability calculations in `services/calculations.py`. - Added new exception class `ProfitabilityValidationError` for handling validation errors. - Created repositories for managing project and scenario profitability snapshots. - Developed a utility script for verifying authenticated routes. - Added a new HTML template for the profitability calculator interface. - Implemented a script to fix user ID sequence in the database.
206 lines
6.3 KiB
Python
206 lines
6.3 KiB
Python
"""Service functions for financial calculations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from services.currency import CurrencyValidationError, normalise_currency
|
|
from services.exceptions import ProfitabilityValidationError
|
|
from services.financial import (
|
|
CashFlow,
|
|
ConvergenceError,
|
|
PaybackNotReachedError,
|
|
internal_rate_of_return,
|
|
net_present_value,
|
|
payback_period,
|
|
)
|
|
from services.pricing import PricingInput, PricingMetadata, PricingResult, calculate_pricing
|
|
from schemas.calculations import (
|
|
CashFlowEntry,
|
|
ProfitabilityCalculationRequest,
|
|
ProfitabilityCalculationResult,
|
|
ProfitabilityCosts,
|
|
ProfitabilityMetrics,
|
|
)
|
|
|
|
|
|
def _build_pricing_input(
|
|
request: ProfitabilityCalculationRequest,
|
|
) -> PricingInput:
|
|
"""Construct a pricing input instance including impurity overrides."""
|
|
|
|
impurity_values: dict[str, float] = {}
|
|
impurity_thresholds: dict[str, float] = {}
|
|
impurity_penalties: dict[str, float] = {}
|
|
|
|
for impurity in request.impurities:
|
|
code = impurity.name.strip()
|
|
if not code:
|
|
continue
|
|
code = code.upper()
|
|
if impurity.value is not None:
|
|
impurity_values[code] = float(impurity.value)
|
|
if impurity.threshold is not None:
|
|
impurity_thresholds[code] = float(impurity.threshold)
|
|
if impurity.penalty is not None:
|
|
impurity_penalties[code] = float(impurity.penalty)
|
|
|
|
pricing_input = PricingInput(
|
|
metal=request.metal,
|
|
ore_tonnage=request.ore_tonnage,
|
|
head_grade_pct=request.head_grade_pct,
|
|
recovery_pct=request.recovery_pct,
|
|
payable_pct=request.payable_pct,
|
|
reference_price=request.reference_price,
|
|
treatment_charge=request.treatment_charge,
|
|
smelting_charge=request.smelting_charge,
|
|
moisture_pct=request.moisture_pct,
|
|
moisture_threshold_pct=request.moisture_threshold_pct,
|
|
moisture_penalty_per_pct=request.moisture_penalty_per_pct,
|
|
impurity_ppm=impurity_values,
|
|
impurity_thresholds=impurity_thresholds,
|
|
impurity_penalty_per_ppm=impurity_penalties,
|
|
premiums=request.premiums,
|
|
fx_rate=request.fx_rate,
|
|
currency_code=request.currency_code,
|
|
)
|
|
|
|
return pricing_input
|
|
|
|
|
|
def _generate_cash_flows(
|
|
*,
|
|
periods: int,
|
|
net_per_period: float,
|
|
initial_capex: float,
|
|
) -> tuple[list[CashFlow], list[CashFlowEntry]]:
|
|
"""Create cash flow structures for financial metric calculations."""
|
|
|
|
cash_flow_models: list[CashFlow] = [
|
|
CashFlow(amount=-initial_capex, period_index=0)
|
|
]
|
|
cash_flow_entries: list[CashFlowEntry] = [
|
|
CashFlowEntry(
|
|
period=0,
|
|
revenue=0.0,
|
|
processing_opex=0.0,
|
|
sustaining_capex=0.0,
|
|
net=-initial_capex,
|
|
)
|
|
]
|
|
|
|
for period in range(1, periods + 1):
|
|
cash_flow_models.append(
|
|
CashFlow(amount=net_per_period, period_index=period))
|
|
cash_flow_entries.append(
|
|
CashFlowEntry(
|
|
period=period,
|
|
revenue=0.0,
|
|
processing_opex=0.0,
|
|
sustaining_capex=0.0,
|
|
net=net_per_period,
|
|
)
|
|
)
|
|
|
|
return cash_flow_models, cash_flow_entries
|
|
|
|
|
|
def calculate_profitability(
|
|
request: ProfitabilityCalculationRequest,
|
|
*,
|
|
metadata: PricingMetadata,
|
|
) -> ProfitabilityCalculationResult:
|
|
"""Calculate profitability metrics using pricing inputs and cost data."""
|
|
|
|
if request.periods <= 0:
|
|
raise ProfitabilityValidationError(
|
|
"Evaluation periods must be at least 1.", ["periods"]
|
|
)
|
|
|
|
pricing_input = _build_pricing_input(request)
|
|
try:
|
|
pricing_result: PricingResult = calculate_pricing(
|
|
pricing_input, metadata=metadata
|
|
)
|
|
except CurrencyValidationError as exc:
|
|
raise ProfitabilityValidationError(
|
|
str(exc), ["currency_code"]) from exc
|
|
|
|
periods = request.periods
|
|
revenue_total = float(pricing_result.net_revenue)
|
|
revenue_per_period = revenue_total / periods
|
|
|
|
processing_total = float(request.processing_opex) * periods
|
|
sustaining_total = float(request.sustaining_capex) * periods
|
|
initial_capex = float(request.initial_capex)
|
|
|
|
net_per_period = (
|
|
revenue_per_period
|
|
- float(request.processing_opex)
|
|
- float(request.sustaining_capex)
|
|
)
|
|
|
|
cash_flow_models, cash_flow_entries = _generate_cash_flows(
|
|
periods=periods,
|
|
net_per_period=net_per_period,
|
|
initial_capex=initial_capex,
|
|
)
|
|
|
|
# Update per-period entries to include explicit costs for presentation
|
|
for entry in cash_flow_entries[1:]:
|
|
entry.revenue = revenue_per_period
|
|
entry.processing_opex = float(request.processing_opex)
|
|
entry.sustaining_capex = float(request.sustaining_capex)
|
|
entry.net = net_per_period
|
|
|
|
discount_rate = (request.discount_rate or 0.0) / 100.0
|
|
|
|
npv_value = net_present_value(discount_rate, cash_flow_models)
|
|
|
|
try:
|
|
irr_value = internal_rate_of_return(cash_flow_models) * 100.0
|
|
except (ValueError, ZeroDivisionError, ConvergenceError):
|
|
irr_value = None
|
|
|
|
try:
|
|
payback_value = payback_period(cash_flow_models)
|
|
except (ValueError, PaybackNotReachedError):
|
|
payback_value = None
|
|
|
|
total_costs = processing_total + sustaining_total + initial_capex
|
|
total_net = revenue_total - total_costs
|
|
|
|
if revenue_total == 0:
|
|
margin_value = None
|
|
else:
|
|
margin_value = (total_net / revenue_total) * 100.0
|
|
|
|
currency = request.currency_code or pricing_result.currency
|
|
try:
|
|
currency = normalise_currency(currency)
|
|
except CurrencyValidationError as exc:
|
|
raise ProfitabilityValidationError(
|
|
str(exc), ["currency_code"]) from exc
|
|
|
|
costs = ProfitabilityCosts(
|
|
processing_opex_total=processing_total,
|
|
sustaining_capex_total=sustaining_total,
|
|
initial_capex=initial_capex,
|
|
)
|
|
|
|
metrics = ProfitabilityMetrics(
|
|
npv=npv_value,
|
|
irr=irr_value,
|
|
payback_period=payback_value,
|
|
margin=margin_value,
|
|
)
|
|
|
|
return ProfitabilityCalculationResult(
|
|
pricing=pricing_result,
|
|
costs=costs,
|
|
metrics=metrics,
|
|
cash_flows=cash_flow_entries,
|
|
currency=currency,
|
|
)
|
|
|
|
|
|
__all__ = ["calculate_profitability"]
|