From 795a9f99f4ec4262a3e606fa7f3867296326ec18 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Tue, 11 Nov 2025 18:29:59 +0100 Subject: [PATCH] feat: Enhance currency handling and validation across scenarios - Updated form template to prefill currency input with default value and added help text for clarity. - Modified integration tests to assert more descriptive error messages for invalid currency codes. - Introduced new tests for currency normalization and validation in various scenarios, including imports and exports. - Added comprehensive tests for pricing calculations, ensuring defaults are respected and overrides function correctly. - Implemented unit tests for pricing settings repository, ensuring CRUD operations and default settings are handled properly. - Enhanced scenario pricing evaluation tests to validate currency handling and metadata defaults. - Added simulation tests to ensure Monte Carlo runs are accurate and handle various distribution scenarios. --- changelog.md | 14 +- config/settings.py | 45 ++ dependencies.py | 26 + main.py | 22 +- models/__init__.py | 8 + models/financial_input.py | 11 +- models/pricing_settings.py | 176 +++++ models/project.py | 11 +- models/scenario.py | 14 +- routes/exports.py | 52 +- routes/projects.py | 13 +- routes/reports.py | 512 +++++++++++++ routes/scenarios.py | 159 +++- schemas/imports.py | 14 +- schemas/scenario.py | 32 +- scripts/initial_data.py | 64 +- services/__init__.py | 9 + services/bootstrap.py | 46 +- services/currency.py | 43 ++ services/export_query.py | 18 +- services/financial.py | 248 +++++++ services/pricing.py | 176 +++++ services/reporting.py | 676 ++++++++++++++++++ services/repositories.py | 304 +++++++- services/scenario_evaluation.py | 54 ++ services/simulation.py | 352 +++++++++ services/unit_of_work.py | 52 +- static/css/main.css | 201 ++++++ templates/partials/reports/filters_card.html | 33 + .../partials/reports/monte_carlo_table.html | 46 ++ templates/partials/reports/options_card.html | 56 ++ .../partials/reports/scenario_actions.html | 14 + templates/partials/reports_header.html | 24 + templates/reports/project_summary.html | 205 ++++++ templates/reports/scenario_comparison.html | 166 +++++ templates/reports/scenario_distribution.html | 149 ++++ templates/scenarios/form.html | 6 +- tests/integration/test_scenario_lifecycle.py | 6 +- tests/test_bootstrap.py | 93 ++- tests/test_currency.py | 42 ++ tests/test_export_routes.py | 14 + tests/test_financial.py | 162 +++++ tests/test_import_api.py | 32 + tests/test_import_parsing.py | 19 + tests/test_pricing.py | 155 ++++ tests/test_pricing_settings_repository.py | 209 ++++++ tests/test_repositories.py | 63 ++ tests/test_scenario_pricing.py | 120 ++++ tests/test_scenario_validation.py | 67 ++ tests/test_simulation.py | 158 ++++ 50 files changed, 5110 insertions(+), 81 deletions(-) create mode 100644 models/pricing_settings.py create mode 100644 routes/reports.py create mode 100644 services/currency.py create mode 100644 services/financial.py create mode 100644 services/pricing.py create mode 100644 services/reporting.py create mode 100644 services/scenario_evaluation.py create mode 100644 services/simulation.py create mode 100644 templates/partials/reports/filters_card.html create mode 100644 templates/partials/reports/monte_carlo_table.html create mode 100644 templates/partials/reports/options_card.html create mode 100644 templates/partials/reports/scenario_actions.html create mode 100644 templates/partials/reports_header.html create mode 100644 templates/reports/project_summary.html create mode 100644 templates/reports/scenario_comparison.html create mode 100644 templates/reports/scenario_distribution.html create mode 100644 tests/test_currency.py create mode 100644 tests/test_financial.py create mode 100644 tests/test_pricing.py create mode 100644 tests/test_pricing_settings_repository.py create mode 100644 tests/test_scenario_pricing.py create mode 100644 tests/test_simulation.py diff --git a/changelog.md b/changelog.md index f30041c..5ba2548 100644 --- a/changelog.md +++ b/changelog.md @@ -24,7 +24,6 @@ ## 2025-11-10 -- Extended authorization helper layer with project/scenario ownership lookups, integrated them into FastAPI dependencies, refreshed pytest fixtures to keep the suite authenticated, and documented the new patterns across RBAC plan and security guides. - Added dedicated pytest coverage for guard dependencies, exercising success plus failure paths (missing session, inactive user, missing roles, project/scenario access errors) via `tests/test_dependencies_guards.py`. - Added integration tests in `tests/test_authorization_integration.py` verifying anonymous 401 responses, role-based 403s, and authorized project manager flows across API and UI endpoints. - Implemented environment-driven admin bootstrap settings, wired the `bootstrap_admin` helper into FastAPI startup, added pytest coverage for creation/idempotency/reset logic, and documented operational guidance in the RBAC plan and security concept. @@ -34,3 +33,16 @@ - Added `services/importers.py` to load CSV/XLSX files into the new import schemas, pulled in `openpyxl` for Excel support, and covered the parsing behaviour with `tests/test_import_parsing.py`. - Expanded the import ingestion workflow with staging previews, transactional persistence commits, FastAPI preview/commit endpoints under `/imports`, and new API tests (`tests/test_import_ingestion.py`, `tests/test_import_api.py`) ensuring end-to-end coverage. - Added persistent audit logging via `ImportExportLog`, structured log emission, Prometheus metrics instrumentation, `/metrics` endpoint exposure, and updated operator/deployment documentation to guide monitoring setup. + +## 2025-11-11 + +- Centralised ISO-4217 currency validation across scenarios, imports, and export filters (`models/scenario.py`, `routes/scenarios.py`, `schemas/scenario.py`, `schemas/imports.py`, `services/export_query.py`) so malformed codes are rejected consistently at every entry point. +- Updated scenario services and UI flows to surface friendly validation errors and added regression coverage for imports, exports, API creation, and lifecycle flows ensuring currencies are normalised end-to-end. +- Recorded the completed “Ensure currency is used consistently” work in `.github/instructions/DONE.md` and ran the full pytest suite (150 tests) to verify the refactor. +- Linked projects to their pricing settings by updating SQLAlchemy models, repositories, seeding utilities, and migrations, and added regression tests to cover the new association and default backfill. +- Bootstrapped database-stored pricing settings at application startup, aligned initial data seeding with the database-first metadata flow, and added tests covering pricing bootstrap creation, project assignment, and idempotency. +- Extended pricing configuration support to prefer persisted metadata via `dependencies.get_pricing_metadata`, added retrieval tests for project/default fallbacks, and refreshed docs (`calminer-docs/specifications/price_calculation.md`, `pricing_settings_data_model.md`) to describe the database-backed workflow and bootstrap behaviour. +- Added `services/financial.py` NPV, IRR, and payback helpers with robust cash-flow normalisation, convergence safeguards, and fractional period support, plus comprehensive pytest coverage exercising representative project scenarios and failure modes. +- Authored `calminer-docs/specifications/financial_metrics.md` capturing DCF assumptions, solver behaviours, and worked examples, and cross-linked the architecture concepts to the new reference for consistent navigation. +- Implemented `services/simulation.py` Monte Carlo engine with configurable distributions, summary aggregation, and reproducible RNG seeding, introduced regression tests in `tests/test_simulation.py`, and documented configuration/usage in `calminer-docs/specifications/monte_carlo_simulation.md` with architecture cross-links. +- Polished reporting HTML contexts by cleaning stray fragments in `routes/reports.py`, adding download action metadata for project and scenario pages, and generating scenario comparison download URLs with correctly serialised repeated `scenario_ids` parameters. diff --git a/config/settings.py b/config/settings.py index 339a879..cb30fb4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -7,6 +7,8 @@ from functools import lru_cache from typing import Optional +from services.pricing import PricingMetadata + from services.security import JWTSettings @@ -56,6 +58,10 @@ class Settings: admin_password: str = "ChangeMe123!" admin_roles: tuple[str, ...] = ("admin",) admin_force_reset: bool = False + pricing_default_payable_pct: float = 100.0 + pricing_default_currency: str | None = "USD" + pricing_moisture_threshold_pct: float = 8.0 + pricing_moisture_penalty_per_pct: float = 0.0 @classmethod def from_environment(cls) -> "Settings": @@ -105,6 +111,18 @@ class Settings: admin_force_reset=cls._bool_from_env( "CALMINER_SEED_FORCE", False ), + pricing_default_payable_pct=cls._float_from_env( + "CALMINER_PRICING_DEFAULT_PAYABLE_PCT", 100.0 + ), + pricing_default_currency=cls._optional_str( + "CALMINER_PRICING_DEFAULT_CURRENCY", "USD" + ), + pricing_moisture_threshold_pct=cls._float_from_env( + "CALMINER_PRICING_MOISTURE_THRESHOLD_PCT", 8.0 + ), + pricing_moisture_penalty_per_pct=cls._float_from_env( + "CALMINER_PRICING_MOISTURE_PENALTY_PER_PCT", 0.0 + ), ) @staticmethod @@ -145,6 +163,23 @@ class Settings: seen.add(role_name) return tuple(ordered) + @staticmethod + def _float_from_env(name: str, default: float) -> float: + raw_value = os.getenv(name) + if raw_value is None: + return default + try: + return float(raw_value) + except ValueError: + return default + + @staticmethod + def _optional_str(name: str, default: str | None = None) -> str | None: + raw_value = os.getenv(name) + if raw_value is None or raw_value.strip() == "": + return default + return raw_value.strip() + def jwt_settings(self) -> JWTSettings: """Build runtime JWT settings compatible with token helpers.""" @@ -180,6 +215,16 @@ class Settings: force_reset=self.admin_force_reset, ) + def pricing_metadata(self) -> PricingMetadata: + """Build pricing metadata defaults.""" + + return PricingMetadata( + default_payable_pct=self.pricing_default_payable_pct, + default_currency=self.pricing_default_currency, + moisture_threshold_pct=self.pricing_moisture_threshold_pct, + moisture_penalty_per_pct=self.pricing_moisture_penalty_per_pct, + ) + @lru_cache(maxsize=1) def get_settings() -> Settings: diff --git a/dependencies.py b/dependencies.py index 100504d..2d18234 100644 --- a/dependencies.py +++ b/dependencies.py @@ -22,6 +22,9 @@ from services.session import ( ) from services.unit_of_work import UnitOfWork from services.importers import ImportIngestionService +from services.pricing import PricingMetadata +from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator +from services.repositories import pricing_settings_to_metadata def get_unit_of_work() -> Generator[UnitOfWork, None, None]: @@ -46,6 +49,29 @@ def get_application_settings() -> Settings: return get_settings() +def get_pricing_metadata( + settings: Settings = Depends(get_application_settings), + uow: UnitOfWork = Depends(get_unit_of_work), +) -> PricingMetadata: + """Return pricing metadata defaults sourced from persisted pricing settings.""" + + stored = uow.get_pricing_metadata() + if stored is not None: + return stored + + fallback = settings.pricing_metadata() + seed_result = uow.ensure_default_pricing_settings(metadata=fallback) + return pricing_settings_to_metadata(seed_result.settings) + + +def get_pricing_evaluator( + metadata: PricingMetadata = Depends(get_pricing_metadata), +) -> ScenarioPricingEvaluator: + """Provide a configured scenario pricing evaluator.""" + + return ScenarioPricingEvaluator(ScenarioPricingConfig(metadata=metadata)) + + def get_jwt_settings() -> JWTSettings: """Provide JWT runtime configuration derived from settings.""" diff --git a/main.py b/main.py index ef58b7f..daf9e7a 100644 --- a/main.py +++ b/main.py @@ -19,9 +19,10 @@ from routes.dashboard import router as dashboard_router from routes.imports import router as imports_router from routes.exports import router as exports_router from routes.projects import router as projects_router +from routes.reports import router as reports_router from routes.scenarios import router as scenarios_router from monitoring import router as monitoring_router -from services.bootstrap import bootstrap_admin +from services.bootstrap import bootstrap_admin, bootstrap_pricing_settings # Initialize database schema (imports above ensure models are registered) Base.metadata.create_all(bind=engine) @@ -47,9 +48,12 @@ async def health() -> dict[str, str]: @app.on_event("startup") async def ensure_admin_bootstrap() -> None: - settings = get_settings().admin_bootstrap_settings() + settings = get_settings() + admin_settings = settings.admin_bootstrap_settings() + pricing_metadata = settings.pricing_metadata() try: - role_result, admin_result = bootstrap_admin(settings=settings) + role_result, admin_result = bootstrap_admin(settings=admin_settings) + pricing_result = bootstrap_pricing_settings(metadata=pricing_metadata) logger.info( "Admin bootstrap completed: roles=%s created=%s updated=%s rotated=%s assigned=%s", role_result.ensured, @@ -58,8 +62,17 @@ async def ensure_admin_bootstrap() -> None: admin_result.password_rotated, admin_result.roles_granted, ) + logger.info( + "Pricing settings bootstrap completed: slug=%s created=%s updated_fields=%s impurity_upserts=%s projects_assigned=%s", + pricing_result.seed.settings.slug, + pricing_result.seed.created, + pricing_result.seed.updated_fields, + pricing_result.seed.impurity_upserts, + pricing_result.projects_assigned, + ) except Exception: # pragma: no cover - defensive logging - logger.exception("Failed to bootstrap administrator account") + logger.exception( + "Failed to bootstrap administrator or pricing settings") app.include_router(dashboard_router) @@ -68,6 +81,7 @@ app.include_router(imports_router) app.include_router(exports_router) app.include_router(projects_router) app.include_router(scenarios_router) +app.include_router(reports_router) app.include_router(monitoring_router) app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/models/__init__.py b/models/__init__.py index 305ffb7..58b518a 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -11,6 +11,11 @@ from .metadata import ( StochasticVariable, StochasticVariableDescriptor, ) +from .pricing_settings import ( + PricingImpuritySettings, + PricingMetalSettings, + PricingSettings, +) from .project import MiningOperationType, Project from .scenario import Scenario, ScenarioStatus from .simulation_parameter import DistributionType, SimulationParameter @@ -21,6 +26,9 @@ __all__ = [ "FinancialInput", "MiningOperationType", "Project", + "PricingSettings", + "PricingMetalSettings", + "PricingImpuritySettings", "Scenario", "ScenarioStatus", "DistributionType", diff --git a/models/financial_input.py b/models/financial_input.py index eecdea2..fbcf69b 100644 --- a/models/financial_input.py +++ b/models/financial_input.py @@ -31,6 +31,7 @@ from sqlalchemy.sql import func from config.database import Base from .metadata import CostBucket +from services.currency import normalise_currency if TYPE_CHECKING: # pragma: no cover from .scenario import Scenario @@ -73,16 +74,12 @@ class FinancialInput(Base): DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) - scenario: Mapped["Scenario"] = relationship("Scenario", back_populates="financial_inputs") + scenario: Mapped["Scenario"] = relationship( + "Scenario", back_populates="financial_inputs") @validates("currency") def _validate_currency(self, key: str, value: str | None) -> str | None: - if value is None: - return value - value = value.upper() - if len(value) != 3: - raise ValueError("Currency code must be a 3-letter ISO 4217 value") - return value + return normalise_currency(value) def __repr__(self) -> str: # pragma: no cover return f"FinancialInput(id={self.id!r}, scenario_id={self.scenario_id!r}, name={self.name!r})" diff --git a/models/pricing_settings.py b/models/pricing_settings.py new file mode 100644 index 0000000..cb6b893 --- /dev/null +++ b/models/pricing_settings.py @@ -0,0 +1,176 @@ +from __future__ import annotations + +"""Database models for persisted pricing configuration settings.""" + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import ( + JSON, + DateTime, + ForeignKey, + Integer, + Numeric, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship, validates +from sqlalchemy.sql import func + +from config.database import Base +from services.currency import normalise_currency + +if TYPE_CHECKING: # pragma: no cover + from .project import Project + + +class PricingSettings(Base): + """Persisted pricing defaults applied to scenario evaluations.""" + + __tablename__ = "pricing_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(128), nullable=False, unique=True) + slug: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + default_currency: Mapped[str | None] = mapped_column( + String(3), nullable=True) + default_payable_pct: Mapped[float] = mapped_column( + Numeric(5, 2), nullable=False, default=100.0 + ) + moisture_threshold_pct: Mapped[float] = mapped_column( + Numeric(5, 2), nullable=False, default=8.0 + ) + moisture_penalty_per_pct: Mapped[float] = mapped_column( + Numeric(14, 4), nullable=False, default=0.0 + ) + metadata_payload: Mapped[dict | None] = mapped_column( + "metadata", JSON, nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + metal_overrides: Mapped[list["PricingMetalSettings"]] = relationship( + "PricingMetalSettings", + back_populates="pricing_settings", + cascade="all, delete-orphan", + passive_deletes=True, + ) + impurity_overrides: Mapped[list["PricingImpuritySettings"]] = relationship( + "PricingImpuritySettings", + back_populates="pricing_settings", + cascade="all, delete-orphan", + passive_deletes=True, + ) + projects: Mapped[list["Project"]] = relationship( + "Project", + back_populates="pricing_settings", + cascade="all", + ) + + @validates("slug") + def _normalise_slug(self, key: str, value: str) -> str: + return value.strip().lower() + + @validates("default_currency") + def _validate_currency(self, key: str, value: str | None) -> str | None: + return normalise_currency(value) + + def __repr__(self) -> str: # pragma: no cover + return f"PricingSettings(id={self.id!r}, slug={self.slug!r})" + + +class PricingMetalSettings(Base): + """Contract-specific overrides for a particular metal.""" + + __tablename__ = "pricing_metal_settings" + __table_args__ = ( + UniqueConstraint( + "pricing_settings_id", "metal_code", name="uq_pricing_metal_settings_code" + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + pricing_settings_id: Mapped[int] = mapped_column( + ForeignKey("pricing_settings.id", ondelete="CASCADE"), nullable=False, index=True + ) + metal_code: Mapped[str] = mapped_column(String(32), nullable=False) + payable_pct: Mapped[float | None] = mapped_column( + Numeric(5, 2), nullable=True) + moisture_threshold_pct: Mapped[float | None] = mapped_column( + Numeric(5, 2), nullable=True) + moisture_penalty_per_pct: Mapped[float | None] = mapped_column( + Numeric(14, 4), nullable=True + ) + data: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + pricing_settings: Mapped["PricingSettings"] = relationship( + "PricingSettings", back_populates="metal_overrides" + ) + + @validates("metal_code") + def _normalise_metal_code(self, key: str, value: str) -> str: + return value.strip().lower() + + def __repr__(self) -> str: # pragma: no cover + return ( + "PricingMetalSettings(" # noqa: ISC001 + f"id={self.id!r}, pricing_settings_id={self.pricing_settings_id!r}, " + f"metal_code={self.metal_code!r})" + ) + + +class PricingImpuritySettings(Base): + """Impurity penalty thresholds associated with pricing settings.""" + + __tablename__ = "pricing_impurity_settings" + __table_args__ = ( + UniqueConstraint( + "pricing_settings_id", + "impurity_code", + name="uq_pricing_impurity_settings_code", + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + pricing_settings_id: Mapped[int] = mapped_column( + ForeignKey("pricing_settings.id", ondelete="CASCADE"), nullable=False, index=True + ) + impurity_code: Mapped[str] = mapped_column(String(32), nullable=False) + threshold_ppm: Mapped[float] = mapped_column( + Numeric(14, 4), nullable=False, default=0.0) + penalty_per_ppm: Mapped[float] = mapped_column( + Numeric(14, 4), nullable=False, default=0.0) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) + + pricing_settings: Mapped["PricingSettings"] = relationship( + "PricingSettings", back_populates="impurity_overrides" + ) + + @validates("impurity_code") + def _normalise_impurity_code(self, key: str, value: str) -> str: + return value.strip().upper() + + def __repr__(self) -> str: # pragma: no cover + return ( + "PricingImpuritySettings(" # noqa: ISC001 + f"id={self.id!r}, pricing_settings_id={self.pricing_settings_id!r}, " + f"impurity_code={self.impurity_code!r})" + ) diff --git a/models/project.py b/models/project.py index de6e0ff..8493df0 100644 --- a/models/project.py +++ b/models/project.py @@ -4,7 +4,7 @@ from datetime import datetime from enum import Enum from typing import TYPE_CHECKING, List -from sqlalchemy import DateTime, Enum as SQLEnum, Integer, String, Text +from sqlalchemy import DateTime, Enum as SQLEnum, ForeignKey, Integer, String, Text from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func @@ -12,6 +12,7 @@ from config.database import Base if TYPE_CHECKING: # pragma: no cover from .scenario import Scenario + from .pricing_settings import PricingSettings class MiningOperationType(str, Enum): @@ -38,6 +39,10 @@ class Project(Base): SQLEnum(MiningOperationType), nullable=False, default=MiningOperationType.OTHER ) description: Mapped[str | None] = mapped_column(Text, nullable=True) + pricing_settings_id: Mapped[int | None] = mapped_column( + ForeignKey("pricing_settings.id", ondelete="SET NULL"), + nullable=True, + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now() ) @@ -51,6 +56,10 @@ class Project(Base): cascade="all, delete-orphan", passive_deletes=True, ) + pricing_settings: Mapped["PricingSettings | None"] = relationship( + "PricingSettings", + back_populates="projects", + ) def __repr__(self) -> str: # pragma: no cover - helpful for debugging return f"Project(id={self.id!r}, name={self.name!r})" diff --git a/models/scenario.py b/models/scenario.py index b7f08b0..0aa2410 100644 --- a/models/scenario.py +++ b/models/scenario.py @@ -14,10 +14,11 @@ from sqlalchemy import ( String, Text, ) -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from sqlalchemy.sql import func from config.database import Base +from services.currency import normalise_currency from .metadata import ResourceType if TYPE_CHECKING: # pragma: no cover @@ -50,7 +51,8 @@ class Scenario(Base): ) start_date: Mapped[date | None] = mapped_column(Date, nullable=True) end_date: Mapped[date | None] = mapped_column(Date, nullable=True) - discount_rate: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + discount_rate: Mapped[float | None] = mapped_column( + Numeric(5, 2), nullable=True) currency: Mapped[str | None] = mapped_column(String(3), nullable=True) primary_resource: Mapped[ResourceType | None] = mapped_column( SQLEnum(ResourceType), nullable=True @@ -62,7 +64,8 @@ class Scenario(Base): DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) - project: Mapped["Project"] = relationship("Project", back_populates="scenarios") + project: Mapped["Project"] = relationship( + "Project", back_populates="scenarios") financial_inputs: Mapped[List["FinancialInput"]] = relationship( "FinancialInput", back_populates="scenario", @@ -76,5 +79,10 @@ class Scenario(Base): passive_deletes=True, ) + @validates("currency") + def _normalise_currency(self, key: str, value: str | None) -> str | None: + # Normalise to uppercase ISO-4217; raises when the code is malformed. + return normalise_currency(value) + def __repr__(self) -> str: # pragma: no cover return f"Scenario(id={self.id!r}, name={self.name!r}, project_id={self.project_id!r})" diff --git a/routes/exports.py b/routes/exports.py index d55c8a1..421b2df 100644 --- a/routes/exports.py +++ b/routes/exports.py @@ -121,9 +121,32 @@ async def export_projects( ) -> Response: project_repo = _ensure_repository( getattr(uow, "projects", None), "Project") + start = time.perf_counter() try: - start = time.perf_counter() projects = project_repo.filtered_for_export(request.filters) + except ValueError as exc: + _record_export_audit( + uow=uow, + dataset="projects", + status="failure", + export_format=request.format, + row_count=0, + filename=None, + ) + logger.warning( + "export.validation_failed", + extra={ + "event": "export", + "dataset": "projects", + "status": "validation_failed", + "format": request.format.value, + "error": str(exc), + }, + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc except Exception as exc: _record_export_audit( uow=uow, @@ -145,7 +168,6 @@ async def export_projects( raise exc filename = f"projects-{_timestamp_suffix()}" - start = time.perf_counter() if request.format == ExportFormat.CSV: stream = stream_projects_to_csv(projects) @@ -226,10 +248,33 @@ async def export_scenarios( ) -> Response: scenario_repo = _ensure_repository( getattr(uow, "scenarios", None), "Scenario") + start = time.perf_counter() try: - start = time.perf_counter() scenarios = scenario_repo.filtered_for_export( request.filters, include_project=True) + except ValueError as exc: + _record_export_audit( + uow=uow, + dataset="scenarios", + status="failure", + export_format=request.format, + row_count=0, + filename=None, + ) + logger.warning( + "export.validation_failed", + extra={ + "event": "export", + "dataset": "scenarios", + "status": "validation_failed", + "format": request.format.value, + "error": str(exc), + }, + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, + detail=str(exc), + ) from exc except Exception as exc: _record_export_audit( uow=uow, @@ -251,7 +296,6 @@ async def export_scenarios( raise exc filename = f"scenarios-{_timestamp_suffix()}" - start = time.perf_counter() if request.format == ExportFormat.CSV: stream = stream_scenarios_to_csv(scenarios) diff --git a/routes/projects.py b/routes/projects.py index f105881..fbade1b 100644 --- a/routes/projects.py +++ b/routes/projects.py @@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from dependencies import ( + get_pricing_metadata, get_unit_of_work, require_any_role, require_project_resource, @@ -15,6 +16,7 @@ from dependencies import ( from models import MiningOperationType, Project, ScenarioStatus, User from schemas.project import ProjectCreate, ProjectRead, ProjectUpdate from services.exceptions import EntityConflictError, EntityNotFoundError +from services.pricing import PricingMetadata from services.unit_of_work import UnitOfWork router = APIRouter(prefix="/projects", tags=["Projects"]) @@ -54,6 +56,7 @@ def create_project( payload: ProjectCreate, _: User = Depends(require_roles(*MANAGE_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), + metadata: PricingMetadata = Depends(get_pricing_metadata), ) -> ProjectRead: project = Project(**payload.model_dump()) try: @@ -62,6 +65,9 @@ def create_project( raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc) ) from exc + default_settings = uow.ensure_default_pricing_settings( + metadata=metadata).settings + uow.set_project_pricing_settings(created, default_settings) return _to_read_model(created) @@ -122,6 +128,7 @@ def create_project_submit( operation_type: str = Form(...), description: str | None = Form(None), uow: UnitOfWork = Depends(get_unit_of_work), + metadata: PricingMetadata = Depends(get_pricing_metadata), ): def _normalise(value: str | None) -> str | None: if value is None: @@ -152,7 +159,7 @@ def create_project_submit( description=_normalise(description), ) try: - _require_project_repo(uow).create(project) + created = _require_project_repo(uow).create(project) except EntityConflictError as exc: return templates.TemplateResponse( request, @@ -167,6 +174,10 @@ def create_project_submit( status_code=status.HTTP_409_CONFLICT, ) + default_settings = uow.ensure_default_pricing_settings( + metadata=metadata).settings + uow.set_project_pricing_settings(created, default_settings) + return RedirectResponse( request.url_for("projects.project_list_page"), status_code=status.HTTP_303_SEE_OTHER, diff --git a/routes/reports.py b/routes/reports.py new file mode 100644 index 0000000..eea21d1 --- /dev/null +++ b/routes/reports.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +from datetime import date +from urllib.parse import urlencode + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import HTMLResponse +from fastapi.templating import Jinja2Templates + +from dependencies import ( + get_unit_of_work, + require_any_role, + require_project_resource, + require_roles, + require_scenario_resource, +) +from models import Project, Scenario, User +from services.exceptions import EntityNotFoundError, ScenarioValidationError +from services.reporting import ( + DEFAULT_ITERATIONS, + IncludeOptions, + ReportFilters, + ReportingService, + parse_include_tokens, + validate_percentiles, +) +from services.unit_of_work import UnitOfWork + +router = APIRouter(prefix="/reports", tags=["Reports"]) +templates = Jinja2Templates(directory="templates") + +READ_ROLES = ("viewer", "analyst", "project_manager", "admin") +MANAGE_ROLES = ("project_manager", "admin") + + +@router.get("/projects/{project_id}", name="reports.project_summary") +def project_summary_report( + project: Project = Depends(require_project_resource()), + _: User = Depends(require_any_role(*READ_ROLES)), + uow: UnitOfWork = Depends(get_unit_of_work), + include: str | None = Query( + None, + description="Comma-separated include tokens (distribution,samples,all).", + ), + scenario_ids: list[int] | None = Query( + None, + alias="scenario_ids", + description="Repeatable scenario identifier filter.", + ), + start_date: date | None = Query( + None, + description="Filter scenarios starting on or after this date.", + ), + end_date: date | None = Query( + None, + description="Filter scenarios ending on or before this date.", + ), + fmt: str = Query( + "json", + alias="format", + description="Response format (json only for this endpoint).", + ), + iterations: int | None = Query( + None, + gt=0, + description="Override Monte Carlo iteration count when distribution is included.", + ), + percentiles: list[float] | None = Query( + None, + description="Percentiles (0-100) for Monte Carlo summaries when included.", + ), +) -> dict[str, object]: + if fmt.lower() != "json": + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, + detail="Only JSON responses are supported; use the HTML endpoint for templates.", + ) + + include_options = parse_include_tokens(include) + try: + percentile_values = validate_percentiles(percentiles) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + scenario_filter = ReportFilters( + scenario_ids=set(scenario_ids) if scenario_ids else None, + start_date=start_date, + end_date=end_date, + ) + + service = ReportingService(uow) + report = service.project_summary( + project, + filters=scenario_filter, + include=include_options, + iterations=iterations or DEFAULT_ITERATIONS, + percentiles=percentile_values, + ) + return jsonable_encoder(report) + + +@router.get( + "/projects/{project_id}/scenarios/compare", + name="reports.project_scenario_comparison", +) +def project_scenario_comparison_report( + project: Project = Depends(require_project_resource()), + _: User = Depends(require_any_role(*READ_ROLES)), + uow: UnitOfWork = Depends(get_unit_of_work), + scenario_ids: list[int] = Query( + ..., alias="scenario_ids", description="Repeatable scenario identifier."), + include: str | None = Query( + None, + description="Comma-separated include tokens (distribution,samples,all).", + ), + fmt: str = Query( + "json", + alias="format", + description="Response format (json only for this endpoint).", + ), + iterations: int | None = Query( + None, + gt=0, + description="Override Monte Carlo iteration count when distribution is included.", + ), + percentiles: list[float] | None = Query( + None, + description="Percentiles (0-100) for Monte Carlo summaries when included.", + ), +) -> dict[str, object]: + unique_ids = list(dict.fromkeys(scenario_ids)) + if len(unique_ids) < 2: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="At least two unique scenario_ids must be provided for comparison.", + ) + if fmt.lower() != "json": + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, + detail="Only JSON responses are supported; use the HTML endpoint for templates.", + ) + + include_options = parse_include_tokens(include) + try: + percentile_values = validate_percentiles(percentiles) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + try: + scenarios = uow.validate_scenarios_for_comparison(unique_ids) + except ScenarioValidationError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": exc.code, + "message": exc.message, + "scenario_ids": list(exc.scenario_ids or []), + }, + ) from exc + except EntityNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + + if any(scenario.project_id != project.id for scenario in scenarios): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="One or more scenarios are not associated with this project.", + ) + + service = ReportingService(uow) + report = service.scenario_comparison( + project, + scenarios, + include=include_options, + iterations=iterations or DEFAULT_ITERATIONS, + percentiles=percentile_values, + ) + return jsonable_encoder(report) + + +@router.get( + "/scenarios/{scenario_id}/distribution", + name="reports.scenario_distribution", +) +def scenario_distribution_report( + scenario: Scenario = Depends(require_scenario_resource()), + _: User = Depends(require_any_role(*READ_ROLES)), + uow: UnitOfWork = Depends(get_unit_of_work), + include: str | None = Query( + None, + description="Comma-separated include tokens (samples,all).", + ), + fmt: str = Query( + "json", + alias="format", + description="Response format (json only for this endpoint).", + ), + iterations: int | None = Query( + None, + gt=0, + description="Override Monte Carlo iteration count (default applies otherwise).", + ), + percentiles: list[float] | None = Query( + None, + description="Percentiles (0-100) for Monte Carlo summaries.", + ), +) -> dict[str, object]: + if fmt.lower() != "json": + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, + detail="Only JSON responses are supported; use the HTML endpoint for templates.", + ) + + requested = parse_include_tokens(include) + include_options = IncludeOptions( + distribution=True, samples=requested.samples) + + try: + percentile_values = validate_percentiles(percentiles) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + service = ReportingService(uow) + report = service.scenario_distribution( + scenario, + include=include_options, + iterations=iterations or DEFAULT_ITERATIONS, + percentiles=percentile_values, + ) + return jsonable_encoder(report) + + +@router.get( + "/projects/{project_id}/ui", + response_class=HTMLResponse, + include_in_schema=False, + name="reports.project_summary_page", +) +def project_summary_page( + request: Request, + project: Project = Depends(require_project_resource()), + _: User = Depends(require_any_role(*READ_ROLES)), + uow: UnitOfWork = Depends(get_unit_of_work), + include: str | None = Query( + None, + description="Comma-separated include tokens (distribution,samples,all).", + ), + scenario_ids: list[int] | None = Query( + None, + alias="scenario_ids", + description="Repeatable scenario identifier filter.", + ), + start_date: date | None = Query( + None, + description="Filter scenarios starting on or after this date.", + ), + end_date: date | None = Query( + None, + description="Filter scenarios ending on or before this date.", + ), + iterations: int | None = Query( + None, + gt=0, + description="Override Monte Carlo iteration count when distribution is included.", + ), + percentiles: list[float] | None = Query( + None, + description="Percentiles (0-100) for Monte Carlo summaries when included.", + ), +) -> HTMLResponse: + include_options = parse_include_tokens(include) + try: + percentile_values = validate_percentiles(percentiles) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + scenario_filter = ReportFilters( + scenario_ids=set(scenario_ids) if scenario_ids else None, + start_date=start_date, + end_date=end_date, + ) + + service = ReportingService(uow) + report = service.project_summary( + project, + filters=scenario_filter, + include=include_options, + iterations=iterations or DEFAULT_ITERATIONS, + percentiles=percentile_values, + ) + context = { + "request": request, + "project": report["project"], + "scenario_count": report["scenario_count"], + "aggregates": report["aggregates"], + "scenarios": report["scenarios"], + "filters": report["filters"], + "include_options": include_options, + "iterations": iterations or DEFAULT_ITERATIONS, + "percentiles": percentile_values, + "title": f"Project Summary · {project.name}", + "subtitle": "Aggregated financial and simulation insights across scenarios.", + "actions": [ + { + "href": request.url_for( + "reports.project_summary", + project_id=project.id, + ), + "label": "Download JSON", + } + ], + } + return templates.TemplateResponse( + request, + "reports/project_summary.html", + context, + ) + + +@router.get( + "/projects/{project_id}/scenarios/compare/ui", + response_class=HTMLResponse, + include_in_schema=False, + name="reports.project_scenario_comparison_page", +) +def project_scenario_comparison_page( + request: Request, + project: Project = Depends(require_project_resource()), + _: User = Depends(require_any_role(*READ_ROLES)), + uow: UnitOfWork = Depends(get_unit_of_work), + scenario_ids: list[int] = Query( + ..., alias="scenario_ids", description="Repeatable scenario identifier."), + include: str | None = Query( + None, + description="Comma-separated include tokens (distribution,samples,all).", + ), + iterations: int | None = Query( + None, + gt=0, + description="Override Monte Carlo iteration count when distribution is included.", + ), + percentiles: list[float] | None = Query( + None, + description="Percentiles (0-100) for Monte Carlo summaries when included.", + ), +) -> HTMLResponse: + unique_ids = list(dict.fromkeys(scenario_ids)) + if len(unique_ids) < 2: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="At least two unique scenario_ids must be provided for comparison.", + ) + + include_options = parse_include_tokens(include) + try: + percentile_values = validate_percentiles(percentiles) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + try: + scenarios = uow.validate_scenarios_for_comparison(unique_ids) + except ScenarioValidationError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail={ + "code": exc.code, + "message": exc.message, + "scenario_ids": list(exc.scenario_ids or []), + }, + ) from exc + except EntityNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + + if any(scenario.project_id != project.id for scenario in scenarios): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="One or more scenarios are not associated with this project.", + ) + + service = ReportingService(uow) + report = service.scenario_comparison( + project, + scenarios, + include=include_options, + iterations=iterations or DEFAULT_ITERATIONS, + percentiles=percentile_values, + ) + comparison_json_url = request.url_for( + "reports.project_scenario_comparison", + project_id=project.id, + ) + comparison_query = urlencode( + [("scenario_ids", str(identifier)) for identifier in unique_ids] + ) + if comparison_query: + comparison_json_url = f"{comparison_json_url}?{comparison_query}" + + context = { + "request": request, + "project": report["project"], + "scenarios": report["scenarios"], + "comparison": report["comparison"], + "include_options": include_options, + "iterations": iterations or DEFAULT_ITERATIONS, + "percentiles": percentile_values, + "title": f"Scenario Comparison · {project.name}", + "subtitle": "Evaluate deterministic metrics and Monte Carlo trends side by side.", + "actions": [ + { + "href": comparison_json_url, + "label": "Download JSON", + } + ], + } + return templates.TemplateResponse( + request, + "reports/scenario_comparison.html", + context, + ) + + +@router.get( + "/scenarios/{scenario_id}/distribution/ui", + response_class=HTMLResponse, + include_in_schema=False, + name="reports.scenario_distribution_page", +) +def scenario_distribution_page( + request: Request, + scenario: Scenario = Depends(require_scenario_resource()), + _: User = Depends(require_any_role(*READ_ROLES)), + uow: UnitOfWork = Depends(get_unit_of_work), + include: str | None = Query( + None, + description="Comma-separated include tokens (samples,all).", + ), + iterations: int | None = Query( + None, + gt=0, + description="Override Monte Carlo iteration count (default applies otherwise).", + ), + percentiles: list[float] | None = Query( + None, + description="Percentiles (0-100) for Monte Carlo summaries.", + ), +) -> HTMLResponse: + requested = parse_include_tokens(include) + include_options = IncludeOptions( + distribution=True, samples=requested.samples) + + try: + percentile_values = validate_percentiles(percentiles) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(exc), + ) from exc + + service = ReportingService(uow) + report = service.scenario_distribution( + scenario, + include=include_options, + iterations=iterations or DEFAULT_ITERATIONS, + percentiles=percentile_values, + ) + context = { + "request": request, + "scenario": report["scenario"], + "summary": report["summary"], + "metrics": report["metrics"], + "monte_carlo": report["monte_carlo"], + "include_options": include_options, + "iterations": iterations or DEFAULT_ITERATIONS, + "percentiles": percentile_values, + "title": f"Scenario Distribution · {scenario.name}", + "subtitle": "Deterministic and simulated distributions for a single scenario.", + "actions": [ + { + "href": request.url_for( + "reports.scenario_distribution", + scenario_id=scenario.id, + ), + "label": "Download JSON", + } + ], + } + return templates.TemplateResponse( + request, + "reports/scenario_distribution.html", + context, + ) diff --git a/routes/scenarios.py b/routes/scenarios.py index 89d5060..b229298 100644 --- a/routes/scenarios.py +++ b/routes/scenarios.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import date +from types import SimpleNamespace from typing import List from fastapi import APIRouter, Depends, Form, HTTPException, Request, status @@ -8,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from dependencies import ( + get_pricing_metadata, get_unit_of_work, require_any_role, require_roles, @@ -21,11 +23,13 @@ from schemas.scenario import ( ScenarioRead, ScenarioUpdate, ) +from services.currency import CurrencyValidationError, normalise_currency from services.exceptions import ( EntityConflictError, EntityNotFoundError, ScenarioValidationError, ) +from services.pricing import PricingMetadata from services.unit_of_work import UnitOfWork router = APIRouter(tags=["Scenarios"]) @@ -143,6 +147,7 @@ def create_scenario_for_project( payload: ScenarioCreate, _: User = Depends(require_roles(*MANAGE_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), + metadata: PricingMetadata = Depends(get_pricing_metadata), ) -> ScenarioRead: project_repo = _require_project_repo(uow) scenario_repo = _require_scenario_repo(uow) @@ -152,7 +157,10 @@ def create_scenario_for_project( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc - scenario = Scenario(project_id=project_id, **payload.model_dump()) + scenario_data = payload.model_dump() + if not scenario_data.get("currency") and metadata.default_currency: + scenario_data["currency"] = metadata.default_currency + scenario = Scenario(project_id=project_id, **scenario_data) try: created = scenario_repo.create(scenario) @@ -219,6 +227,33 @@ def _parse_discount_rate(value: str | None) -> float | None: return None +def _scenario_form_state( + *, + project_id: int, + name: str, + description: str | None, + status: ScenarioStatus, + start_date: date | None, + end_date: date | None, + discount_rate: float | None, + currency: str | None, + primary_resource: ResourceType | None, + scenario_id: int | None = None, +) -> SimpleNamespace: + return SimpleNamespace( + id=scenario_id, + project_id=project_id, + name=name, + description=description, + status=status, + start_date=start_date, + end_date=end_date, + discount_rate=discount_rate, + currency=currency, + primary_resource=primary_resource, + ) + + @router.get( "/projects/{project_id}/scenarios/new", response_class=HTMLResponse, @@ -230,6 +265,7 @@ def create_scenario_form( request: Request, _: User = Depends(require_roles(*MANAGE_ROLES)), uow: UnitOfWork = Depends(get_unit_of_work), + metadata: PricingMetadata = Depends(get_pricing_metadata), ) -> HTMLResponse: try: project = _require_project_repo(uow).get(project_id) @@ -252,6 +288,7 @@ def create_scenario_form( "cancel_url": request.url_for( "projects.view_project", project_id=project_id ), + "default_currency": metadata.default_currency, }, ) @@ -274,6 +311,7 @@ def create_scenario_submit( currency: str | None = Form(None), primary_resource: str | None = Form(None), uow: UnitOfWork = Depends(get_unit_of_work), + metadata: PricingMetadata = Depends(get_pricing_metadata), ): project_repo = _require_project_repo(uow) scenario_repo = _require_scenario_repo(uow) @@ -296,17 +334,59 @@ def create_scenario_submit( except ValueError: resource_enum = None - currency_value = _normalise(currency) - currency_value = currency_value.upper() if currency_value else None + name_value = name.strip() + description_value = _normalise(description) + start_date_value = _parse_date(start_date) + end_date_value = _parse_date(end_date) + discount_rate_value = _parse_discount_rate(discount_rate) + currency_input = _normalise(currency) + effective_currency = currency_input or metadata.default_currency + + try: + currency_value = ( + normalise_currency(effective_currency) + if effective_currency else None + ) + except CurrencyValidationError as exc: + form_state = _scenario_form_state( + project_id=project_id, + name=name_value, + description=description_value, + status=status_enum, + start_date=start_date_value, + end_date=end_date_value, + discount_rate=discount_rate_value, + currency=currency_input or metadata.default_currency, + primary_resource=resource_enum, + ) + return templates.TemplateResponse( + request, + "scenarios/form.html", + { + "project": project, + "scenario": form_state, + "scenario_statuses": _scenario_status_choices(), + "resource_types": _resource_type_choices(), + "form_action": request.url_for( + "scenarios.create_scenario_submit", project_id=project_id + ), + "cancel_url": request.url_for( + "projects.view_project", project_id=project_id + ), + "error": str(exc), + "default_currency": metadata.default_currency, + }, + status_code=status.HTTP_400_BAD_REQUEST, + ) scenario = Scenario( project_id=project_id, - name=name.strip(), - description=_normalise(description), + name=name_value, + description=description_value, status=status_enum, - start_date=_parse_date(start_date), - end_date=_parse_date(end_date), - discount_rate=_parse_discount_rate(discount_rate), + start_date=start_date_value, + end_date=end_date_value, + discount_rate=discount_rate_value, currency=currency_value, primary_resource=resource_enum, ) @@ -329,6 +409,7 @@ def create_scenario_submit( "projects.view_project", project_id=project_id ), "error": "Scenario could not be created.", + "default_currency": metadata.default_currency, }, status_code=status.HTTP_409_CONFLICT, ) @@ -392,6 +473,7 @@ def edit_scenario_form( require_scenario_resource(require_manage=True) ), uow: UnitOfWork = Depends(get_unit_of_work), + metadata: PricingMetadata = Depends(get_pricing_metadata), ) -> HTMLResponse: project = _require_project_repo(uow).get(scenario.project_id) @@ -409,6 +491,7 @@ def edit_scenario_form( "cancel_url": request.url_for( "scenarios.view_scenario", scenario_id=scenario.id ), + "default_currency": metadata.default_currency, }, ) @@ -432,22 +515,17 @@ def edit_scenario_submit( currency: str | None = Form(None), primary_resource: str | None = Form(None), uow: UnitOfWork = Depends(get_unit_of_work), + metadata: PricingMetadata = Depends(get_pricing_metadata), ): project = _require_project_repo(uow).get(scenario.project_id) - scenario.name = name.strip() - scenario.description = _normalise(description) + name_value = name.strip() + description_value = _normalise(description) try: scenario.status = ScenarioStatus(status_value) except ValueError: scenario.status = ScenarioStatus.DRAFT - scenario.start_date = _parse_date(start_date) - scenario.end_date = _parse_date(end_date) - - scenario.discount_rate = _parse_discount_rate(discount_rate) - - currency_value = _normalise(currency) - scenario.currency = currency_value.upper() if currency_value else None + status_enum = scenario.status resource_enum = None if primary_resource: @@ -455,6 +533,53 @@ def edit_scenario_submit( resource_enum = ResourceType(primary_resource) except ValueError: resource_enum = None + + start_date_value = _parse_date(start_date) + end_date_value = _parse_date(end_date) + discount_rate_value = _parse_discount_rate(discount_rate) + currency_input = _normalise(currency) + + try: + currency_value = normalise_currency(currency_input) + except CurrencyValidationError as exc: + form_state = _scenario_form_state( + scenario_id=scenario.id, + project_id=scenario.project_id, + name=name_value, + description=description_value, + status=status_enum, + start_date=start_date_value, + end_date=end_date_value, + discount_rate=discount_rate_value, + currency=currency_input, + primary_resource=resource_enum, + ) + return templates.TemplateResponse( + request, + "scenarios/form.html", + { + "project": project, + "scenario": form_state, + "scenario_statuses": _scenario_status_choices(), + "resource_types": _resource_type_choices(), + "form_action": request.url_for( + "scenarios.edit_scenario_submit", scenario_id=scenario.id + ), + "cancel_url": request.url_for( + "scenarios.view_scenario", scenario_id=scenario.id + ), + "error": str(exc), + "default_currency": metadata.default_currency, + }, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + scenario.name = name_value + scenario.description = description_value + scenario.start_date = start_date_value + scenario.end_date = end_date_value + scenario.discount_rate = discount_rate_value + scenario.currency = currency_value scenario.primary_resource = resource_enum uow.flush() diff --git a/schemas/imports.py b/schemas/imports.py index c60b9a7..e9f3895 100644 --- a/schemas/imports.py +++ b/schemas/imports.py @@ -7,6 +7,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from models import MiningOperationType, ResourceType, ScenarioStatus +from services.currency import CurrencyValidationError, normalise_currency PreviewStateLiteral = Literal["new", "update", "skip", "error"] @@ -142,14 +143,13 @@ class ScenarioImportRow(BaseModel): @field_validator("currency", mode="before") @classmethod def normalise_currency(cls, value: Any | None) -> str | None: - if value is None: + text = _strip_or_none(value) + if text is None: return None - text = _normalise_string(value).upper() - if not text: - return None - if len(text) != 3: - raise ValueError("Currency code must be a 3-letter ISO value") - return text + try: + return normalise_currency(text) + except CurrencyValidationError as exc: + raise ValueError(str(exc)) from exc @field_validator("discount_rate", mode="before") @classmethod diff --git a/schemas/scenario.py b/schemas/scenario.py index 73573d6..4295868 100644 --- a/schemas/scenario.py +++ b/schemas/scenario.py @@ -5,6 +5,7 @@ from datetime import date, datetime from pydantic import BaseModel, ConfigDict, field_validator, model_validator from models import ResourceType, ScenarioStatus +from services.currency import CurrencyValidationError, normalise_currency class ScenarioBase(BaseModel): @@ -23,11 +24,15 @@ class ScenarioBase(BaseModel): @classmethod def normalise_currency(cls, value: str | None) -> str | None: if value is None: - return value - value = value.upper() - if len(value) != 3: - raise ValueError("Currency code must be a 3-letter ISO value") - return value + return None + candidate = value if isinstance(value, str) else str(value) + candidate = candidate.strip() + if not candidate: + return None + try: + return normalise_currency(candidate) + except CurrencyValidationError as exc: + raise ValueError(str(exc)) from exc class ScenarioCreate(ScenarioBase): @@ -50,11 +55,15 @@ class ScenarioUpdate(BaseModel): @classmethod def normalise_currency(cls, value: str | None) -> str | None: if value is None: - return value - value = value.upper() - if len(value) != 3: - raise ValueError("Currency code must be a 3-letter ISO value") - return value + return None + candidate = value if isinstance(value, str) else str(value) + candidate = candidate.strip() + if not candidate: + return None + try: + return normalise_currency(candidate) + except CurrencyValidationError as exc: + raise ValueError(str(exc)) from exc class ScenarioRead(ScenarioBase): @@ -75,7 +84,8 @@ class ScenarioComparisonRequest(BaseModel): def ensure_minimum_ids(self) -> "ScenarioComparisonRequest": unique_ids: list[int] = list(dict.fromkeys(self.scenario_ids)) if len(unique_ids) < 2: - raise ValueError("At least two unique scenario identifiers are required for comparison.") + raise ValueError( + "At least two unique scenario identifiers are required for comparison.") self.scenario_ids = unique_ids return self diff --git a/scripts/initial_data.py b/scripts/initial_data.py index bd877b7..fa752e8 100644 --- a/scripts/initial_data.py +++ b/scripts/initial_data.py @@ -7,8 +7,15 @@ from typing import Callable, Iterable from dotenv import load_dotenv +from config.settings import Settings from models import Role, User -from services.repositories import DEFAULT_ROLE_DEFINITIONS, RoleRepository, UserRepository +from services.repositories import ( + DEFAULT_ROLE_DEFINITIONS, + PricingSettingsSeedResult, + RoleRepository, + UserRepository, + ensure_default_pricing_settings, +) from services.unit_of_work import UnitOfWork @@ -45,7 +52,8 @@ def parse_bool(value: str | None) -> bool: def normalise_role_list(raw_value: str | None) -> tuple[str, ...]: if not raw_value: return ("admin",) - parts = [segment.strip() for segment in raw_value.split(",") if segment.strip()] + parts = [segment.strip() + for segment in raw_value.split(",") if segment.strip()] if "admin" not in parts: parts.insert(0, "admin") seen: set[str] = set() @@ -59,7 +67,8 @@ def normalise_role_list(raw_value: str | None) -> tuple[str, ...]: def load_config() -> SeedConfig: load_dotenv() - admin_email = os.getenv("CALMINER_SEED_ADMIN_EMAIL", "admin@calminer.local") + admin_email = os.getenv("CALMINER_SEED_ADMIN_EMAIL", + "admin@calminer.local") admin_username = os.getenv("CALMINER_SEED_ADMIN_USERNAME", "admin") admin_password = os.getenv("CALMINER_SEED_ADMIN_PASSWORD", "ChangeMe123!") admin_roles = normalise_role_list(os.getenv("CALMINER_SEED_ADMIN_ROLES")) @@ -140,12 +149,15 @@ def ensure_admin_user( for role_name in config.admin_roles: role = role_repo.get_by_name(role_name) if role is None: - logging.warning("Role '%s' is not defined and will be skipped", role_name) + logging.warning( + "Role '%s' is not defined and will be skipped", role_name) continue - already_assigned = any(assignment.role_id == role.id for assignment in user.role_assignments) + already_assigned = any(assignment.role_id == + role.id for assignment in user.role_assignments) if already_assigned: continue - user_repo.assign_role(user_id=user.id, role_id=role.id, granted_by=user.id) + user_repo.assign_role( + user_id=user.id, role_id=role.id, granted_by=user.id) roles_granted += 1 return AdminSeedResult( @@ -164,9 +176,33 @@ def seed_initial_data( logging.info("Starting initial data seeding") factory = unit_of_work_factory or UnitOfWork with factory() as uow: - assert uow.roles is not None and uow.users is not None + assert ( + uow.roles is not None + and uow.users is not None + and uow.pricing_settings is not None + and uow.projects is not None + ) role_result = ensure_default_roles(uow.roles) admin_result = ensure_admin_user(uow.users, uow.roles, config) + pricing_metadata = uow.get_pricing_metadata() + metadata_source = "database" + if pricing_metadata is None: + pricing_metadata = Settings.from_environment().pricing_metadata() + metadata_source = "environment" + pricing_result: PricingSettingsSeedResult = ensure_default_pricing_settings( + uow.pricing_settings, + metadata=pricing_metadata, + ) + + projects_without_pricing = [ + project + for project in uow.projects.list(with_pricing=True) + if project.pricing_settings is None + ] + assigned_projects = 0 + for project in projects_without_pricing: + uow.set_project_pricing_settings(project, pricing_result.settings) + assigned_projects += 1 logging.info( "Roles processed: %s total, %s created, %s updated", role_result.total, @@ -180,4 +216,16 @@ def seed_initial_data( admin_result.password_rotated, admin_result.roles_granted, ) - logging.info("Initial data seeding completed successfully") \ No newline at end of file + logging.info( + "Pricing settings ensured (source=%s): slug=%s created=%s updated_fields=%s impurity_upserts=%s", + metadata_source, + pricing_result.settings.slug, + pricing_result.created, + pricing_result.updated_fields, + pricing_result.impurity_upserts, + ) + logging.info( + "Projects updated with default pricing settings: %s", + assigned_projects, + ) + logging.info("Initial data seeding completed successfully") diff --git a/services/__init__.py b/services/__init__.py index 1476ae9..c6e3b26 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -1 +1,10 @@ """Service layer utilities.""" + +from .pricing import calculate_pricing, PricingInput, PricingMetadata, PricingResult + +__all__ = [ + "calculate_pricing", + "PricingInput", + "PricingMetadata", + "PricingResult", +] diff --git a/services/bootstrap.py b/services/bootstrap.py index 4ccb4d4..025996c 100644 --- a/services/bootstrap.py +++ b/services/bootstrap.py @@ -6,7 +6,11 @@ from typing import Callable from config.settings import AdminBootstrapSettings from models import User -from services.repositories import ensure_default_roles +from services.pricing import PricingMetadata +from services.repositories import ( + PricingSettingsSeedResult, + ensure_default_roles, +) from services.unit_of_work import UnitOfWork @@ -27,6 +31,12 @@ class AdminBootstrapResult: roles_granted: int +@dataclass(slots=True) +class PricingBootstrapResult: + seed: PricingSettingsSeedResult + projects_assigned: int + + def bootstrap_admin( *, settings: AdminBootstrapSettings, @@ -127,3 +137,37 @@ def _bootstrap_admin_user( password_rotated=password_rotated, roles_granted=roles_granted, ) + + +def bootstrap_pricing_settings( + *, + metadata: PricingMetadata, + unit_of_work_factory: Callable[[], UnitOfWork] = UnitOfWork, + default_slug: str = "default", +) -> PricingBootstrapResult: + """Ensure baseline pricing settings exist and projects reference them.""" + + with unit_of_work_factory() as uow: + seed_result = uow.ensure_default_pricing_settings( + metadata=metadata, + slug=default_slug, + ) + + assigned = 0 + if uow.projects: + default_settings = seed_result.settings + projects = uow.projects.list(with_pricing=True) + for project in projects: + if project.pricing_settings is None: + uow.set_project_pricing_settings(project, default_settings) + assigned += 1 + + logger.info( + "Pricing bootstrap result: slug=%s created=%s updated_fields=%s impurity_upserts=%s projects_assigned=%s", + seed_result.settings.slug, + seed_result.created, + seed_result.updated_fields, + seed_result.impurity_upserts, + assigned, + ) + return PricingBootstrapResult(seed=seed_result, projects_assigned=assigned) diff --git a/services/currency.py b/services/currency.py new file mode 100644 index 0000000..fa036f6 --- /dev/null +++ b/services/currency.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +"""Utilities for currency normalization within pricing and financial workflows.""" + +import re +from dataclasses import dataclass + +VALID_CURRENCY_PATTERN = re.compile(r"^[A-Z]{3}$") + + +@dataclass(frozen=True) +class CurrencyValidationError(ValueError): + """Raised when a currency code fails validation.""" + + code: str + + def __str__(self) -> str: # pragma: no cover - dataclass repr not required in tests + return f"Invalid currency code: {self.code!r}" + + +def normalise_currency(code: str | None) -> str | None: + """Normalise currency codes to uppercase ISO-4217 values.""" + + if code is None: + return None + candidate = code.strip().upper() + if not VALID_CURRENCY_PATTERN.match(candidate): + raise CurrencyValidationError(candidate) + return candidate + + +def require_currency(code: str | None, default: str | None = None) -> str: + """Return normalised currency code, falling back to default when missing.""" + + normalised = normalise_currency(code) + if normalised is not None: + return normalised + if default is None: + raise CurrencyValidationError("") + fallback = normalise_currency(default) + if fallback is None: + raise CurrencyValidationError("") + return fallback diff --git a/services/export_query.py b/services/export_query.py index acbcb6e..7f6acf9 100644 --- a/services/export_query.py +++ b/services/export_query.py @@ -5,6 +5,7 @@ from datetime import date, datetime from typing import Iterable from models import MiningOperationType, ResourceType, ScenarioStatus +from services.currency import CurrencyValidationError, normalise_currency def _normalise_lower_strings(values: Iterable[str]) -> tuple[str, ...]: @@ -19,15 +20,22 @@ def _normalise_lower_strings(values: Iterable[str]) -> tuple[str, ...]: return tuple(sorted(unique)) -def _normalise_upper_strings(values: Iterable[str]) -> tuple[str, ...]: +def _normalise_upper_strings(values: Iterable[str | None]) -> tuple[str, ...]: unique: set[str] = set() for value in values: - if not value: + if value is None: continue - trimmed = value.strip().upper() - if not trimmed: + candidate = value if isinstance(value, str) else str(value) + candidate = candidate.strip() + if not candidate: continue - unique.add(trimmed) + try: + normalised = normalise_currency(candidate) + except CurrencyValidationError as exc: + raise ValueError(str(exc)) from exc + if normalised is None: + continue + unique.add(normalised) return tuple(sorted(unique)) diff --git a/services/financial.py b/services/financial.py new file mode 100644 index 0000000..93d8c3d --- /dev/null +++ b/services/financial.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +"""Financial calculation helpers for project evaluation metrics.""" + +from dataclasses import dataclass +from datetime import date, datetime +from math import isclose, isfinite +from typing import Iterable, List, Sequence, Tuple + +Number = float + + +@dataclass(frozen=True, slots=True) +class CashFlow: + """Represents a dated cash flow in scenario currency.""" + + amount: Number + period_index: int | None = None + date: date | datetime | None = None + + +class ConvergenceError(RuntimeError): + """Raised when an iterative solver fails to converge.""" + + +class PaybackNotReachedError(RuntimeError): + """Raised when cumulative cash flows never reach a non-negative total.""" + + +def _coerce_date(value: date | datetime) -> date: + if isinstance(value, datetime): + return value.date() + return value + + +def normalize_cash_flows( + cash_flows: Iterable[CashFlow], + *, + compounds_per_year: int = 1, +) -> List[Tuple[Number, float]]: + """Normalise cash flows to ``(amount, periods)`` tuples. + + When explicit ``period_index`` values are provided they take precedence. If + only dates are supplied, the first dated cash flow anchors the timeline and + subsequent cash flows convert their day offsets into fractional periods + based on ``compounds_per_year``. When neither a period index nor a date is + present, cash flows are treated as sequential periods in input order. + """ + + flows: Sequence[CashFlow] = list(cash_flows) + if not flows: + return [] + + if compounds_per_year <= 0: + raise ValueError("compounds_per_year must be a positive integer") + + base_date: date | None = None + for flow in flows: + if flow.date is not None: + base_date = _coerce_date(flow.date) + break + + normalised: List[Tuple[Number, float]] = [] + for idx, flow in enumerate(flows): + amount = float(flow.amount) + if flow.period_index is not None: + periods = float(flow.period_index) + elif flow.date is not None and base_date is not None: + current_date = _coerce_date(flow.date) + delta_days = (current_date - base_date).days + period_length_days = 365.0 / float(compounds_per_year) + periods = delta_days / period_length_days + else: + periods = float(idx) + normalised.append((amount, periods)) + + return normalised + + +def discount_factor(rate: Number, periods: float, *, compounds_per_year: int = 1) -> float: + """Return the factor used to discount a value ``periods`` steps in the future.""" + + if compounds_per_year <= 0: + raise ValueError("compounds_per_year must be a positive integer") + + periodic_rate = rate / float(compounds_per_year) + return (1.0 + periodic_rate) ** (-periods) + + +def net_present_value( + rate: Number, + cash_flows: Iterable[CashFlow], + *, + residual_value: Number | None = None, + residual_periods: float | None = None, + compounds_per_year: int = 1, +) -> float: + """Calculate Net Present Value for ``cash_flows``. + + ``rate`` is a decimal (``0.1`` for 10%). Cash flows are discounted using the + given compounding frequency. When ``residual_value`` is provided it is + discounted at ``residual_periods`` periods; by default the value occurs one + period after the final cash flow. + """ + + normalised = normalize_cash_flows( + cash_flows, + compounds_per_year=compounds_per_year, + ) + + if not normalised and residual_value is None: + return 0.0 + + total = 0.0 + for amount, periods in normalised: + factor = discount_factor( + rate, periods, compounds_per_year=compounds_per_year) + total += amount * factor + + if residual_value is not None: + if residual_periods is None: + last_period = normalised[-1][1] if normalised else 0.0 + residual_periods = last_period + 1.0 + factor = discount_factor( + rate, residual_periods, compounds_per_year=compounds_per_year) + total += float(residual_value) * factor + + return total + + +def internal_rate_of_return( + cash_flows: Iterable[CashFlow], + *, + guess: Number = 0.1, + max_iterations: int = 100, + tolerance: float = 1e-6, + compounds_per_year: int = 1, +) -> float: + """Return the internal rate of return for ``cash_flows``. + + Uses Newton-Raphson iteration with a bracketed fallback when the derivative + becomes unstable. Raises :class:`ConvergenceError` if no root is found. + """ + + flows = normalize_cash_flows( + cash_flows, + compounds_per_year=compounds_per_year, + ) + if not flows: + raise ValueError("cash_flows must contain at least one item") + + amounts = [amount for amount, _ in flows] + if not any(amount < 0 for amount in amounts) or not any(amount > 0 for amount in amounts): + raise ValueError("cash_flows must include both negative and positive values") + + def _npv_with_flows(rate: float) -> float: + periodic_rate = rate / float(compounds_per_year) + if periodic_rate <= -1.0: + return float("inf") + total = 0.0 + for amount, periods in flows: + factor = (1.0 + periodic_rate) ** (-periods) + total += amount * factor + return total + + def _derivative(rate: float) -> float: + periodic_rate = rate / float(compounds_per_year) + if periodic_rate <= -1.0: + return float("inf") + derivative = 0.0 + for amount, periods in flows: + factor = (1.0 + periodic_rate) ** (-periods - 1.0) + derivative += -amount * periods * factor / float(compounds_per_year) + return derivative + + rate = float(guess) + for _ in range(max_iterations): + value = _npv_with_flows(rate) + if isclose(value, 0.0, abs_tol=tolerance): + return rate + derivative = _derivative(rate) + if derivative == 0.0 or not isfinite(derivative): + break + next_rate = rate - value / derivative + if abs(next_rate - rate) < tolerance: + return next_rate + rate = next_rate + + # Fallback to bracketed bisection between sensible bounds. + lower_bound = -0.99 * float(compounds_per_year) + upper_bound = 10.0 + lower_value = _npv_with_flows(lower_bound) + upper_value = _npv_with_flows(upper_bound) + + attempts = 0 + while lower_value * upper_value > 0 and attempts < 12: + upper_bound *= 2.0 + upper_value = _npv_with_flows(upper_bound) + attempts += 1 + + if lower_value * upper_value > 0: + raise ConvergenceError("IRR could not be bracketed within default bounds") + + for _ in range(max_iterations * 2): + midpoint = (lower_bound + upper_bound) / 2.0 + mid_value = _npv_with_flows(midpoint) + if isclose(mid_value, 0.0, abs_tol=tolerance): + return midpoint + if lower_value * mid_value < 0: + upper_bound = midpoint + upper_value = mid_value + else: + lower_bound = midpoint + lower_value = mid_value + raise ConvergenceError("IRR solver failed to converge") + + +def payback_period( + cash_flows: Iterable[CashFlow], + *, + allow_fractional: bool = True, + compounds_per_year: int = 1, +) -> float: + """Return the period index where cumulative cash flow becomes non-negative.""" + + flows = normalize_cash_flows( + cash_flows, + compounds_per_year=compounds_per_year, + ) + if not flows: + raise ValueError("cash_flows must contain at least one item") + + flows = sorted(flows, key=lambda item: item[1]) + cumulative = 0.0 + previous_period = flows[0][1] + + for index, (amount, periods) in enumerate(flows): + next_cumulative = cumulative + amount + if next_cumulative >= 0.0: + if not allow_fractional or isclose(amount, 0.0): + return periods + prev_period = previous_period if index > 0 else periods + fraction = -cumulative / amount + return prev_period + fraction * (periods - prev_period) + cumulative = next_cumulative + previous_period = periods + + raise PaybackNotReachedError("Cumulative cash flow never becomes non-negative") diff --git a/services/pricing.py b/services/pricing.py new file mode 100644 index 0000000..5ddbe50 --- /dev/null +++ b/services/pricing.py @@ -0,0 +1,176 @@ +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, + ) diff --git a/services/reporting.py b/services/reporting.py new file mode 100644 index 0000000..5ff5ef3 --- /dev/null +++ b/services/reporting.py @@ -0,0 +1,676 @@ +from __future__ import annotations + +"""Reporting service layer aggregating deterministic and simulation metrics.""" + +from dataclasses import dataclass, field +from datetime import date +import math +from typing import Iterable, Mapping, Sequence + +from models import FinancialCategory, Project, Scenario +from services.financial import ( + CashFlow, + ConvergenceError, + PaybackNotReachedError, + internal_rate_of_return, + net_present_value, + payback_period, +) +from services.simulation import ( + CashFlowSpec, + SimulationConfig, + SimulationMetric, + SimulationResult, + run_monte_carlo, +) +from services.unit_of_work import UnitOfWork + +DEFAULT_DISCOUNT_RATE = 0.1 +DEFAULT_ITERATIONS = 500 +DEFAULT_PERCENTILES: tuple[float, float, float] = (5.0, 50.0, 95.0) + +_COST_CATEGORY_SIGNS: Mapping[FinancialCategory, float] = { + FinancialCategory.REVENUE: 1.0, + FinancialCategory.CAPITAL_EXPENDITURE: -1.0, + FinancialCategory.OPERATING_EXPENDITURE: -1.0, + FinancialCategory.CONTINGENCY: -1.0, + FinancialCategory.OTHER: -1.0, +} + + +@dataclass(frozen=True) +class IncludeOptions: + """Flags controlling optional sections in report payloads.""" + + distribution: bool = False + samples: bool = False + + +@dataclass(slots=True) +class ReportFilters: + """Filter parameters applied when selecting scenarios for a report.""" + + scenario_ids: set[int] | None = None + start_date: date | None = None + end_date: date | None = None + + def matches(self, scenario: Scenario) -> bool: + if self.scenario_ids is not None and scenario.id not in self.scenario_ids: + return False + if self.start_date and scenario.start_date and scenario.start_date < self.start_date: + return False + if self.end_date and scenario.end_date and scenario.end_date > self.end_date: + return False + return True + + def to_dict(self) -> dict[str, object]: + payload: dict[str, object] = {} + if self.scenario_ids is not None: + payload["scenario_ids"] = sorted(self.scenario_ids) + if self.start_date is not None: + payload["start_date"] = self.start_date + if self.end_date is not None: + payload["end_date"] = self.end_date + return payload + + +@dataclass(slots=True) +class ScenarioFinancialTotals: + currency: str | None + inflows: float + outflows: float + net: float + by_category: dict[str, float] + + def to_dict(self) -> dict[str, object]: + return { + "currency": self.currency, + "inflows": _round_optional(self.inflows), + "outflows": _round_optional(self.outflows), + "net": _round_optional(self.net), + "by_category": { + key: _round_optional(value) for key, value in sorted(self.by_category.items()) + }, + } + + +@dataclass(slots=True) +class ScenarioDeterministicMetrics: + currency: str | None + discount_rate: float + compounds_per_year: int + npv: float | None + irr: float | None + payback_period: float | None + notes: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, object]: + return { + "currency": self.currency, + "discount_rate": _round_optional(self.discount_rate, digits=4), + "compounds_per_year": self.compounds_per_year, + "npv": _round_optional(self.npv), + "irr": _round_optional(self.irr, digits=6), + "payback_period": _round_optional(self.payback_period, digits=4), + "notes": self.notes, + } + + +@dataclass(slots=True) +class ScenarioMonteCarloResult: + available: bool + notes: list[str] = field(default_factory=list) + result: SimulationResult | None = None + include_samples: bool = False + + def to_dict(self) -> dict[str, object]: + if not self.available or self.result is None: + return { + "available": False, + "notes": self.notes, + } + + metrics: dict[str, dict[str, object]] = {} + for metric, summary in self.result.summaries.items(): + metrics[metric.value] = { + "mean": _round_optional(summary.mean), + "std_dev": _round_optional(summary.std_dev), + "minimum": _round_optional(summary.minimum), + "maximum": _round_optional(summary.maximum), + "percentiles": { + f"{percentile:g}": _round_optional(value) + for percentile, value in sorted(summary.percentiles.items()) + }, + "sample_size": summary.sample_size, + "failed_runs": summary.failed_runs, + } + + samples_payload: dict[str, list[float | None]] | None = None + if self.include_samples and self.result.samples: + samples_payload = {} + for metric, samples in self.result.samples.items(): + samples_payload[metric.value] = [ + _sanitize_float(sample) for sample in samples.tolist() + ] + + payload: dict[str, object] = { + "available": True, + "iterations": self.result.iterations, + "metrics": metrics, + "notes": self.notes, + } + if samples_payload: + payload["samples"] = samples_payload + return payload + + +@dataclass(slots=True) +class ScenarioReport: + scenario: Scenario + totals: ScenarioFinancialTotals + deterministic: ScenarioDeterministicMetrics + monte_carlo: ScenarioMonteCarloResult | None + + def to_dict(self) -> dict[str, object]: + scenario_info = { + "id": self.scenario.id, + "project_id": self.scenario.project_id, + "name": self.scenario.name, + "description": self.scenario.description, + "status": self.scenario.status.value, + "start_date": self.scenario.start_date, + "end_date": self.scenario.end_date, + "currency": self.scenario.currency, + "primary_resource": self.scenario.primary_resource.value + if self.scenario.primary_resource + else None, + "discount_rate": _round_optional(self.deterministic.discount_rate, digits=4), + "created_at": self.scenario.created_at, + "updated_at": self.scenario.updated_at, + "simulation_parameter_count": len(self.scenario.simulation_parameters or []), + } + payload: dict[str, object] = { + "scenario": scenario_info, + "financials": self.totals.to_dict(), + "metrics": self.deterministic.to_dict(), + } + if self.monte_carlo is not None: + payload["monte_carlo"] = self.monte_carlo.to_dict() + return payload + + +@dataclass(slots=True) +class AggregatedMetric: + average: float | None + minimum: float | None + maximum: float | None + + def to_dict(self) -> dict[str, object]: + return { + "average": _round_optional(self.average), + "minimum": _round_optional(self.minimum), + "maximum": _round_optional(self.maximum), + } + + +@dataclass(slots=True) +class ProjectAggregates: + total_inflows: float + total_outflows: float + total_net: float + deterministic_metrics: dict[str, AggregatedMetric] + + def to_dict(self) -> dict[str, object]: + return { + "financials": { + "total_inflows": _round_optional(self.total_inflows), + "total_outflows": _round_optional(self.total_outflows), + "total_net": _round_optional(self.total_net), + }, + "deterministic_metrics": { + metric: data.to_dict() + for metric, data in sorted(self.deterministic_metrics.items()) + }, + } + + +@dataclass(slots=True) +class MetricComparison: + metric: str + direction: str + best: tuple[int, str, float] | None + worst: tuple[int, str, float] | None + average: float | None + + def to_dict(self) -> dict[str, object]: + return { + "metric": self.metric, + "direction": self.direction, + "best": _comparison_entry(self.best), + "worst": _comparison_entry(self.worst), + "average": _round_optional(self.average), + } + + +def parse_include_tokens(raw: str | None) -> IncludeOptions: + tokens: set[str] = set() + if raw: + for part in raw.split(","): + token = part.strip().lower() + if token: + tokens.add(token) + if "all" in tokens: + return IncludeOptions(distribution=True, samples=True) + return IncludeOptions( + distribution=bool({"distribution", "monte_carlo", "mc"} & tokens), + samples="samples" in tokens, + ) + + +def validate_percentiles(values: Sequence[float] | None) -> tuple[float, ...]: + if not values: + return DEFAULT_PERCENTILES + seen: set[float] = set() + cleaned: list[float] = [] + for value in values: + percentile = float(value) + if percentile < 0.0 or percentile > 100.0: + raise ValueError("Percentiles must be between 0 and 100.") + if percentile not in seen: + seen.add(percentile) + cleaned.append(percentile) + if not cleaned: + return DEFAULT_PERCENTILES + return tuple(cleaned) + + +class ReportingService: + """Coordinates project and scenario reporting aggregation.""" + + def __init__(self, uow: UnitOfWork) -> None: + self._uow = uow + + def project_summary( + self, + project: Project, + *, + filters: ReportFilters, + include: IncludeOptions, + iterations: int, + percentiles: tuple[float, ...], + ) -> dict[str, object]: + scenarios = self._load_scenarios(project.id, filters) + reports = [ + self._build_scenario_report( + scenario, + include_distribution=include.distribution, + include_samples=include.samples, + iterations=iterations, + percentiles=percentiles, + ) + for scenario in scenarios + ] + aggregates = self._aggregate_project(reports) + return { + "project": _project_payload(project), + "scenario_count": len(reports), + "filters": filters.to_dict(), + "aggregates": aggregates.to_dict(), + "scenarios": [report.to_dict() for report in reports], + } + + def scenario_comparison( + self, + project: Project, + scenarios: Sequence[Scenario], + *, + include: IncludeOptions, + iterations: int, + percentiles: tuple[float, ...], + ) -> dict[str, object]: + reports = [ + self._build_scenario_report( + self._reload_scenario(scenario.id), + include_distribution=include.distribution, + include_samples=include.samples, + iterations=iterations, + percentiles=percentiles, + ) + for scenario in scenarios + ] + comparison = { + metric: data.to_dict() + for metric, data in self._build_comparisons(reports).items() + } + return { + "project": _project_payload(project), + "scenarios": [report.to_dict() for report in reports], + "comparison": comparison, + } + + def scenario_distribution( + self, + scenario: Scenario, + *, + include: IncludeOptions, + iterations: int, + percentiles: tuple[float, ...], + ) -> dict[str, object]: + report = self._build_scenario_report( + self._reload_scenario(scenario.id), + include_distribution=True, + include_samples=include.samples, + iterations=iterations, + percentiles=percentiles, + ) + return { + "scenario": report.to_dict()["scenario"], + "summary": report.totals.to_dict(), + "metrics": report.deterministic.to_dict(), + "monte_carlo": ( + report.monte_carlo.to_dict() if report.monte_carlo else { + "available": False} + ), + } + + def _load_scenarios(self, project_id: int, filters: ReportFilters) -> list[Scenario]: + repo = self._require_scenario_repo() + scenarios = repo.list_for_project(project_id, with_children=True) + return [scenario for scenario in scenarios if filters.matches(scenario)] + + def _reload_scenario(self, scenario_id: int) -> Scenario: + repo = self._require_scenario_repo() + return repo.get(scenario_id, with_children=True) + + def _build_scenario_report( + self, + scenario: Scenario, + *, + include_distribution: bool, + include_samples: bool, + iterations: int, + percentiles: tuple[float, ...], + ) -> ScenarioReport: + cash_flows, totals = _build_cash_flows(scenario) + deterministic = _calculate_deterministic_metrics( + scenario, cash_flows, totals) + monte_carlo: ScenarioMonteCarloResult | None = None + if include_distribution: + monte_carlo = _run_monte_carlo( + scenario, + cash_flows, + include_samples=include_samples, + iterations=iterations, + percentiles=percentiles, + ) + return ScenarioReport( + scenario=scenario, + totals=totals, + deterministic=deterministic, + monte_carlo=monte_carlo, + ) + + def _aggregate_project(self, reports: Sequence[ScenarioReport]) -> ProjectAggregates: + total_inflows = sum(report.totals.inflows for report in reports) + total_outflows = sum(report.totals.outflows for report in reports) + total_net = sum(report.totals.net for report in reports) + + metrics: dict[str, AggregatedMetric] = {} + for metric_name in ("npv", "irr", "payback_period"): + values = [ + getattr(report.deterministic, metric_name) + for report in reports + if getattr(report.deterministic, metric_name) is not None + ] + if values: + metrics[metric_name] = AggregatedMetric( + average=sum(values) / len(values), + minimum=min(values), + maximum=max(values), + ) + return ProjectAggregates( + total_inflows=total_inflows, + total_outflows=total_outflows, + total_net=total_net, + deterministic_metrics=metrics, + ) + + def _build_comparisons( + self, reports: Sequence[ScenarioReport] + ) -> Mapping[str, MetricComparison]: + comparisons: dict[str, MetricComparison] = {} + for metric_name, direction in ( + ("npv", "higher_is_better"), + ("irr", "higher_is_better"), + ("payback_period", "lower_is_better"), + ): + entries: list[tuple[int, str, float]] = [] + for report in reports: + value = getattr(report.deterministic, metric_name) + if value is None: + continue + entries.append( + (report.scenario.id, report.scenario.name, value)) + if not entries: + continue + if direction == "higher_is_better": + best = max(entries, key=lambda item: item[2]) + worst = min(entries, key=lambda item: item[2]) + else: + best = min(entries, key=lambda item: item[2]) + worst = max(entries, key=lambda item: item[2]) + average = sum(item[2] for item in entries) / len(entries) + comparisons[metric_name] = MetricComparison( + metric=metric_name, + direction=direction, + best=best, + worst=worst, + average=average, + ) + return comparisons + + def _require_scenario_repo(self): + if not self._uow.scenarios: + raise RuntimeError("Scenario repository not initialised") + return self._uow.scenarios + + +def _build_cash_flows(scenario: Scenario) -> tuple[list[CashFlow], ScenarioFinancialTotals]: + cash_flows: list[CashFlow] = [] + by_category: dict[str, float] = {} + inflows = 0.0 + outflows = 0.0 + net = 0.0 + period_index = 0 + + for financial_input in scenario.financial_inputs or []: + sign = _COST_CATEGORY_SIGNS.get(financial_input.category, -1.0) + amount = float(financial_input.amount) * sign + net += amount + if amount >= 0: + inflows += amount + else: + outflows += -amount + by_category.setdefault(financial_input.category.value, 0.0) + by_category[financial_input.category.value] += amount + + if financial_input.effective_date is not None: + cash_flows.append( + CashFlow(amount=amount, date=financial_input.effective_date) + ) + else: + cash_flows.append( + CashFlow(amount=amount, period_index=period_index)) + period_index += 1 + + currency = scenario.currency + if currency is None and scenario.financial_inputs: + currency = scenario.financial_inputs[0].currency + + totals = ScenarioFinancialTotals( + currency=currency, + inflows=inflows, + outflows=outflows, + net=net, + by_category=by_category, + ) + return cash_flows, totals + + +def _calculate_deterministic_metrics( + scenario: Scenario, + cash_flows: Sequence[CashFlow], + totals: ScenarioFinancialTotals, +) -> ScenarioDeterministicMetrics: + notes: list[str] = [] + discount_rate = _normalise_discount_rate(scenario.discount_rate) + if scenario.discount_rate is None: + notes.append( + f"Discount rate not set; defaulted to {discount_rate:.2%}." + ) + + if not cash_flows: + notes.append( + "No financial inputs available for deterministic metrics.") + return ScenarioDeterministicMetrics( + currency=totals.currency, + discount_rate=discount_rate, + compounds_per_year=1, + npv=None, + irr=None, + payback_period=None, + notes=notes, + ) + + npv_value: float | None + try: + npv_value = net_present_value( + discount_rate, + cash_flows, + compounds_per_year=1, + ) + except ValueError as exc: + npv_value = None + notes.append(f"NPV unavailable: {exc}.") + + irr_value: float | None + try: + irr_value = internal_rate_of_return( + cash_flows, + compounds_per_year=1, + ) + except (ValueError, ConvergenceError) as exc: + irr_value = None + notes.append(f"IRR unavailable: {exc}.") + + payback_value: float | None + try: + payback_value = payback_period( + cash_flows, + compounds_per_year=1, + ) + except (ValueError, PaybackNotReachedError) as exc: + payback_value = None + notes.append(f"Payback period unavailable: {exc}.") + + return ScenarioDeterministicMetrics( + currency=totals.currency, + discount_rate=discount_rate, + compounds_per_year=1, + npv=npv_value, + irr=irr_value, + payback_period=payback_value, + notes=notes, + ) + + +def _run_monte_carlo( + scenario: Scenario, + cash_flows: Sequence[CashFlow], + *, + include_samples: bool, + iterations: int, + percentiles: tuple[float, ...], +) -> ScenarioMonteCarloResult: + if not cash_flows: + return ScenarioMonteCarloResult( + available=False, + notes=["No financial inputs available for Monte Carlo simulation."], + ) + + discount_rate = _normalise_discount_rate(scenario.discount_rate) + specs = [CashFlowSpec(cash_flow=flow) for flow in cash_flows] + notes: list[str] = [] + if not scenario.simulation_parameters: + notes.append( + "Scenario has no stochastic parameters; simulation mirrors deterministic cash flows." + ) + config = SimulationConfig( + iterations=iterations, + discount_rate=discount_rate, + metrics=( + SimulationMetric.NPV, + SimulationMetric.IRR, + SimulationMetric.PAYBACK, + ), + percentiles=percentiles, + return_samples=include_samples, + ) + try: + result = run_monte_carlo(specs, config) + except Exception as exc: # pragma: no cover - safeguard for unexpected failures + notes.append(f"Simulation failed: {exc}.") + return ScenarioMonteCarloResult(available=False, notes=notes) + return ScenarioMonteCarloResult( + available=True, + notes=notes, + result=result, + include_samples=include_samples, + ) + + +def _normalise_discount_rate(value: float | None) -> float: + if value is None: + return DEFAULT_DISCOUNT_RATE + rate = float(value) + if rate > 1.0: + return rate / 100.0 + return rate + + +def _sanitize_float(value: float | None) -> float | None: + if value is None: + return None + if math.isnan(value) or math.isinf(value): + return None + return float(value) + + +def _round_optional(value: float | None, *, digits: int = 2) -> float | None: + clean = _sanitize_float(value) + if clean is None: + return None + return round(clean, digits) + + +def _comparison_entry(entry: tuple[int, str, float] | None) -> dict[str, object] | None: + if entry is None: + return None + scenario_id, name, value = entry + return { + "scenario_id": scenario_id, + "name": name, + "value": _round_optional(value), + } + + +def _project_payload(project: Project) -> dict[str, object]: + return { + "id": project.id, + "name": project.name, + "location": project.location, + "operation_type": project.operation_type.value, + "description": project.description, + "created_at": project.created_at, + "updated_at": project.updated_at, + } diff --git a/services/repositories.py b/services/repositories.py index 189dea8..1323a8f 100644 --- a/services/repositories.py +++ b/services/repositories.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Iterable +from dataclasses import dataclass from datetime import datetime from typing import Mapping, Sequence @@ -11,6 +12,9 @@ from sqlalchemy.orm import Session, joinedload, selectinload from models import ( FinancialInput, Project, + PricingImpuritySettings, + PricingMetalSettings, + PricingSettings, ResourceType, Role, Scenario, @@ -21,6 +25,7 @@ from models import ( ) from services.exceptions import EntityConflictError, EntityNotFoundError from services.export_query import ProjectExportFilters, ScenarioExportFilters +from services.pricing import PricingMetadata class ProjectRepository: @@ -29,10 +34,17 @@ class ProjectRepository: def __init__(self, session: Session) -> None: self.session = session - def list(self, *, with_children: bool = False) -> Sequence[Project]: + def list( + self, + *, + with_children: bool = False, + with_pricing: bool = False, + ) -> Sequence[Project]: stmt = select(Project).order_by(Project.created_at) if with_children: stmt = stmt.options(selectinload(Project.scenarios)) + if with_pricing: + stmt = stmt.options(selectinload(Project.pricing_settings)) return self.session.execute(stmt).scalars().all() def count(self) -> int: @@ -47,10 +59,18 @@ class ProjectRepository: ) return self.session.execute(stmt).scalars().all() - def get(self, project_id: int, *, with_children: bool = False) -> Project: + def get( + self, + project_id: int, + *, + with_children: bool = False, + with_pricing: bool = False, + ) -> Project: stmt = select(Project).where(Project.id == project_id) if with_children: stmt = stmt.options(joinedload(Project.scenarios)) + if with_pricing: + stmt = stmt.options(joinedload(Project.pricing_settings)) result = self.session.execute(stmt) if with_children: result = result.unique() @@ -86,10 +106,13 @@ class ProjectRepository: filters: ProjectExportFilters | None = None, *, include_scenarios: bool = False, + include_pricing: bool = False, ) -> Sequence[Project]: stmt = select(Project) if include_scenarios: stmt = stmt.options(selectinload(Project.scenarios)) + if include_pricing: + stmt = stmt.options(selectinload(Project.pricing_settings)) if filters: ids = filters.normalised_ids() @@ -131,6 +154,18 @@ class ProjectRepository: project = self.get(project_id) self.session.delete(project) + def set_pricing_settings( + self, + project: Project, + pricing_settings: PricingSettings | None, + ) -> Project: + project.pricing_settings = pricing_settings + project.pricing_settings_id = ( + pricing_settings.id if pricing_settings is not None else None + ) + self.session.flush() + return project + class ScenarioRepository: """Persistence operations for Scenario entities.""" @@ -138,13 +173,26 @@ class ScenarioRepository: def __init__(self, session: Session) -> None: self.session = session - def list_for_project(self, project_id: int) -> Sequence[Scenario]: + def list_for_project( + self, + project_id: int, + *, + with_children: bool = False, + ) -> Sequence[Scenario]: stmt = ( select(Scenario) .where(Scenario.project_id == project_id) .order_by(Scenario.created_at) ) - return self.session.execute(stmt).scalars().all() + if with_children: + stmt = stmt.options( + selectinload(Scenario.financial_inputs), + selectinload(Scenario.simulation_parameters), + ) + result = self.session.execute(stmt) + if with_children: + result = result.unique() + return result.scalars().all() def count(self) -> int: stmt = select(func.count(Scenario.id)) @@ -376,6 +424,101 @@ class SimulationParameterRepository: self.session.delete(entity) +class PricingSettingsRepository: + """Persistence operations for pricing configuration entities.""" + + def __init__(self, session: Session) -> None: + self.session = session + + def list(self, *, include_children: bool = False) -> Sequence[PricingSettings]: + stmt = select(PricingSettings).order_by(PricingSettings.created_at) + if include_children: + stmt = stmt.options( + selectinload(PricingSettings.metal_overrides), + selectinload(PricingSettings.impurity_overrides), + ) + result = self.session.execute(stmt) + if include_children: + result = result.unique() + return result.scalars().all() + + def get(self, settings_id: int, *, include_children: bool = False) -> PricingSettings: + stmt = select(PricingSettings).where(PricingSettings.id == settings_id) + if include_children: + stmt = stmt.options( + selectinload(PricingSettings.metal_overrides), + selectinload(PricingSettings.impurity_overrides), + ) + result = self.session.execute(stmt) + if include_children: + result = result.unique() + settings = result.scalar_one_or_none() + if settings is None: + raise EntityNotFoundError( + f"Pricing settings {settings_id} not found") + return settings + + def find_by_slug( + self, + slug: str, + *, + include_children: bool = False, + ) -> PricingSettings | None: + normalised = slug.strip().lower() + stmt = select(PricingSettings).where( + PricingSettings.slug == normalised) + if include_children: + stmt = stmt.options( + selectinload(PricingSettings.metal_overrides), + selectinload(PricingSettings.impurity_overrides), + ) + result = self.session.execute(stmt) + if include_children: + result = result.unique() + return result.scalar_one_or_none() + + def get_by_slug(self, slug: str, *, include_children: bool = False) -> PricingSettings: + settings = self.find_by_slug(slug, include_children=include_children) + if settings is None: + raise EntityNotFoundError( + f"Pricing settings slug '{slug}' not found" + ) + return settings + + def create(self, settings: PricingSettings) -> PricingSettings: + self.session.add(settings) + try: + self.session.flush() + except IntegrityError as exc: # pragma: no cover - relies on DB constraints + raise EntityConflictError( + "Pricing settings violates constraints") from exc + return settings + + def delete(self, settings_id: int) -> None: + settings = self.get(settings_id, include_children=True) + self.session.delete(settings) + + def attach_metal_override( + self, + settings: PricingSettings, + override: PricingMetalSettings, + ) -> PricingMetalSettings: + settings.metal_overrides.append(override) + self.session.add(override) + self.session.flush() + return override + + def attach_impurity_override( + self, + settings: PricingSettings, + override: PricingImpuritySettings, + ) -> PricingImpuritySettings: + settings.impurity_overrides.append(override) + self.session.add(override) + self.session.flush() + return override + + class RoleRepository: """Persistence operations for Role entities.""" @@ -507,6 +650,159 @@ class UserRepository: self.session.flush() +DEFAULT_PRICING_SETTINGS_NAME = "Default Pricing Settings" +DEFAULT_PRICING_SETTINGS_DESCRIPTION = ( + "Default pricing configuration generated from environment metadata." +) + + +@dataclass(slots=True) +class PricingSettingsSeedResult: + settings: PricingSettings + created: bool + updated_fields: int + impurity_upserts: int + + +def ensure_default_pricing_settings( + repo: PricingSettingsRepository, + *, + metadata: PricingMetadata, + slug: str = "default", + name: str | None = None, + description: str | None = None, +) -> PricingSettingsSeedResult: + """Ensure a baseline pricing settings record exists and matches metadata defaults.""" + + normalised_slug = (slug or "default").strip().lower() or "default" + target_name = name or DEFAULT_PRICING_SETTINGS_NAME + target_description = description or DEFAULT_PRICING_SETTINGS_DESCRIPTION + + updated_fields = 0 + impurity_upserts = 0 + + try: + settings = repo.get_by_slug(normalised_slug, include_children=True) + created = False + except EntityNotFoundError: + settings = PricingSettings( + name=target_name, + slug=normalised_slug, + description=target_description, + default_currency=metadata.default_currency, + default_payable_pct=metadata.default_payable_pct, + moisture_threshold_pct=metadata.moisture_threshold_pct, + moisture_penalty_per_pct=metadata.moisture_penalty_per_pct, + ) + settings.metadata_payload = None + settings = repo.create(settings) + created = True + else: + if settings.name != target_name: + settings.name = target_name + updated_fields += 1 + if target_description and settings.description != target_description: + settings.description = target_description + updated_fields += 1 + if settings.default_currency != metadata.default_currency: + settings.default_currency = metadata.default_currency + updated_fields += 1 + if float(settings.default_payable_pct) != float(metadata.default_payable_pct): + settings.default_payable_pct = metadata.default_payable_pct + updated_fields += 1 + if float(settings.moisture_threshold_pct) != float(metadata.moisture_threshold_pct): + settings.moisture_threshold_pct = metadata.moisture_threshold_pct + updated_fields += 1 + if float(settings.moisture_penalty_per_pct) != float(metadata.moisture_penalty_per_pct): + settings.moisture_penalty_per_pct = metadata.moisture_penalty_per_pct + updated_fields += 1 + + impurity_thresholds = { + code.strip().upper(): float(value) + for code, value in (metadata.impurity_thresholds or {}).items() + if code.strip() + } + impurity_penalties = { + code.strip().upper(): float(value) + for code, value in (metadata.impurity_penalty_per_ppm or {}).items() + if code.strip() + } + + if impurity_thresholds or impurity_penalties: + existing_map = { + override.impurity_code: override + for override in settings.impurity_overrides + } + target_codes = set(impurity_thresholds) | set(impurity_penalties) + for code in sorted(target_codes): + threshold_value = impurity_thresholds.get(code, 0.0) + penalty_value = impurity_penalties.get(code, 0.0) + existing = existing_map.get(code) + if existing is None: + repo.attach_impurity_override( + settings, + PricingImpuritySettings( + impurity_code=code, + threshold_ppm=threshold_value, + penalty_per_ppm=penalty_value, + ), + ) + impurity_upserts += 1 + continue + changed = False + if float(existing.threshold_ppm) != float(threshold_value): + existing.threshold_ppm = threshold_value + changed = True + if float(existing.penalty_per_ppm) != float(penalty_value): + existing.penalty_per_ppm = penalty_value + changed = True + if changed: + updated_fields += 1 + + if updated_fields > 0 or impurity_upserts > 0: + repo.session.flush() + + return PricingSettingsSeedResult( + settings=settings, + created=created, + updated_fields=updated_fields, + impurity_upserts=impurity_upserts, + ) + + +def pricing_settings_to_metadata(settings: PricingSettings) -> PricingMetadata: + """Convert a persisted pricing settings record into metadata defaults.""" + + payload = settings.metadata_payload or {} + payload_thresholds = payload.get("impurity_thresholds") or {} + payload_penalties = payload.get("impurity_penalty_per_ppm") or {} + + thresholds: dict[str, float] = { + code.strip().upper(): float(value) + for code, value in payload_thresholds.items() + if isinstance(code, str) and code.strip() + } + penalties: dict[str, float] = { + code.strip().upper(): float(value) + for code, value in payload_penalties.items() + if isinstance(code, str) and code.strip() + } + + for override in settings.impurity_overrides: + code = override.impurity_code.strip().upper() + thresholds[code] = float(override.threshold_ppm) + penalties[code] = float(override.penalty_per_ppm) + + return PricingMetadata( + default_payable_pct=float(settings.default_payable_pct), + default_currency=settings.default_currency, + moisture_threshold_pct=float(settings.moisture_threshold_pct), + moisture_penalty_per_pct=float(settings.moisture_penalty_per_pct), + impurity_thresholds=thresholds, + impurity_penalty_per_ppm=penalties, + ) + + DEFAULT_ROLE_DEFINITIONS: tuple[dict[str, str], ...] = ( { "name": "admin", diff --git a/services/scenario_evaluation.py b/services/scenario_evaluation.py new file mode 100644 index 0000000..c3aab15 --- /dev/null +++ b/services/scenario_evaluation.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +"""Scenario evaluation services including pricing integration.""" + +from dataclasses import dataclass +from typing import Iterable, Mapping + +from models.scenario import Scenario +from services.pricing import ( + PricingInput, + PricingMetadata, + PricingResult, + calculate_pricing, +) + + +@dataclass(slots=True) +class ScenarioPricingConfig: + """Configuration for pricing evaluation within a scenario.""" + + metadata: PricingMetadata | None = None + + +@dataclass(slots=True) +class ScenarioPricingSnapshot: + """Captured pricing results for a scenario.""" + + scenario_id: int + results: list[PricingResult] + + +class ScenarioPricingEvaluator: + """Evaluate scenario profitability inputs using pricing services.""" + + def __init__(self, config: ScenarioPricingConfig | None = None) -> None: + self._config = config or ScenarioPricingConfig() + + def evaluate( + self, + scenario: Scenario, + *, + inputs: Iterable[PricingInput], + metadata_override: PricingMetadata | None = None, + ) -> ScenarioPricingSnapshot: + metadata = metadata_override or self._config.metadata + results: list[PricingResult] = [] + for pricing_input in inputs: + result = calculate_pricing( + pricing_input, + metadata=metadata, + currency=scenario.currency, + ) + results.append(result) + return ScenarioPricingSnapshot(scenario_id=scenario.id, results=results) diff --git a/services/simulation.py b/services/simulation.py new file mode 100644 index 0000000..02ae588 --- /dev/null +++ b/services/simulation.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from typing import Any, Dict, Iterable, Mapping, Sequence + +import numpy as np +from numpy.random import Generator, default_rng + +from .financial import ( + CashFlow, + ConvergenceError, + PaybackNotReachedError, + internal_rate_of_return, + net_present_value, + payback_period, +) + + +class DistributionConfigError(ValueError): + """Raised when a distribution specification is invalid.""" + + +class SimulationMetric(Enum): + """Supported Monte Carlo summary metrics.""" + + NPV = "npv" + IRR = "irr" + PAYBACK = "payback" + + +class DistributionType(Enum): + """Supported probability distribution families.""" + + NORMAL = "normal" + LOGNORMAL = "lognormal" + TRIANGULAR = "triangular" + DISCRETE = "discrete" + + +class DistributionSource(Enum): + """Origins for parameter values when sourcing dynamically.""" + + STATIC = "static" + SCENARIO_FIELD = "scenario_field" + METADATA_KEY = "metadata_key" + + +@dataclass(frozen=True, slots=True) +class DistributionSpec: + """Defines the stochastic behaviour for a single cash flow.""" + + type: DistributionType + parameters: Mapping[str, Any] + source: DistributionSource = DistributionSource.STATIC + source_key: str | None = None + + +@dataclass(frozen=True, slots=True) +class CashFlowSpec: + """Pairs a baseline cash flow with an optional distribution.""" + + cash_flow: CashFlow + distribution: DistributionSpec | None = None + + +@dataclass(frozen=True, slots=True) +class SimulationConfig: + """Controls Monte Carlo simulation behaviour.""" + + iterations: int + discount_rate: float + seed: int | None = None + metrics: Sequence[SimulationMetric] = ( + SimulationMetric.NPV, SimulationMetric.IRR, SimulationMetric.PAYBACK) + percentiles: Sequence[float] = (5.0, 50.0, 95.0) + compounds_per_year: int = 1 + return_samples: bool = False + residual_value: float | None = None + residual_periods: float | None = None + + +@dataclass(frozen=True, slots=True) +class MetricSummary: + """Aggregated statistics for a simulated metric.""" + + mean: float + std_dev: float + minimum: float + maximum: float + percentiles: Mapping[float, float] + sample_size: int + failed_runs: int + + +@dataclass(frozen=True, slots=True) +class SimulationResult: + """Monte Carlo output including per-metric summaries.""" + + iterations: int + summaries: Mapping[SimulationMetric, MetricSummary] + samples: Mapping[SimulationMetric, np.ndarray] | None = None + + +def run_monte_carlo( + cash_flows: Sequence[CashFlowSpec], + config: SimulationConfig, + *, + scenario_context: Mapping[str, Any] | None = None, + metadata: Mapping[str, Any] | None = None, + rng: Generator | None = None, +) -> SimulationResult: + """Execute Monte Carlo simulation for the provided cash flows.""" + + if config.iterations <= 0: + raise ValueError("iterations must be greater than zero") + if config.compounds_per_year <= 0: + raise ValueError("compounds_per_year must be greater than zero") + for pct in config.percentiles: + if pct < 0.0 or pct > 100.0: + raise ValueError("percentiles must be within [0, 100]") + + generator = rng or default_rng(config.seed) + + metric_arrays: Dict[SimulationMetric, np.ndarray] = { + metric: np.empty(config.iterations, dtype=float) + for metric in config.metrics + } + + for idx in range(config.iterations): + iteration_flows = [ + _realise_cash_flow( + spec, + generator, + scenario_context=scenario_context, + metadata=metadata, + ) + for spec in cash_flows + ] + + if SimulationMetric.NPV in metric_arrays: + metric_arrays[SimulationMetric.NPV][idx] = net_present_value( + config.discount_rate, + iteration_flows, + residual_value=config.residual_value, + residual_periods=config.residual_periods, + compounds_per_year=config.compounds_per_year, + ) + if SimulationMetric.IRR in metric_arrays: + try: + metric_arrays[SimulationMetric.IRR][idx] = internal_rate_of_return( + iteration_flows, + compounds_per_year=config.compounds_per_year, + ) + except (ValueError, ConvergenceError): + metric_arrays[SimulationMetric.IRR][idx] = np.nan + if SimulationMetric.PAYBACK in metric_arrays: + try: + metric_arrays[SimulationMetric.PAYBACK][idx] = payback_period( + iteration_flows, + compounds_per_year=config.compounds_per_year, + ) + except (ValueError, PaybackNotReachedError): + metric_arrays[SimulationMetric.PAYBACK][idx] = np.nan + + summaries = { + metric: _summarise(metric_arrays[metric], config.percentiles) + for metric in metric_arrays + } + + samples = metric_arrays if config.return_samples else None + return SimulationResult( + iterations=config.iterations, + summaries=summaries, + samples=samples, + ) + + +def _realise_cash_flow( + spec: CashFlowSpec, + generator: Generator, + *, + scenario_context: Mapping[str, Any] | None, + metadata: Mapping[str, Any] | None, +) -> CashFlow: + if spec.distribution is None: + return spec.cash_flow + + distribution = spec.distribution + base_amount = spec.cash_flow.amount + params = _resolve_parameters( + distribution, + base_amount, + scenario_context=scenario_context, + metadata=metadata, + ) + sample = _sample_distribution( + distribution.type, + params, + generator, + ) + return CashFlow( + amount=float(sample), + period_index=spec.cash_flow.period_index, + date=spec.cash_flow.date, + ) + + +def _resolve_parameters( + distribution: DistributionSpec, + base_amount: float, + *, + scenario_context: Mapping[str, Any] | None, + metadata: Mapping[str, Any] | None, +) -> Dict[str, Any]: + params = dict(distribution.parameters) + + if distribution.source == DistributionSource.SCENARIO_FIELD: + if distribution.source_key is None: + raise DistributionConfigError( + "source_key is required for scenario_field sourcing") + if not scenario_context or distribution.source_key not in scenario_context: + raise DistributionConfigError( + f"scenario field '{distribution.source_key}' not found for distribution" + ) + params.setdefault("mean", float( + scenario_context[distribution.source_key])) + elif distribution.source == DistributionSource.METADATA_KEY: + if distribution.source_key is None: + raise DistributionConfigError( + "source_key is required for metadata_key sourcing") + if not metadata or distribution.source_key not in metadata: + raise DistributionConfigError( + f"metadata key '{distribution.source_key}' not found for distribution" + ) + params.setdefault("mean", float(metadata[distribution.source_key])) + else: + params.setdefault("mean", float(base_amount)) + + return params + + +def _sample_distribution( + distribution_type: DistributionType, + params: Mapping[str, Any], + generator: Generator, +) -> float: + if distribution_type is DistributionType.NORMAL: + return _sample_normal(params, generator) + if distribution_type is DistributionType.LOGNORMAL: + return _sample_lognormal(params, generator) + if distribution_type is DistributionType.TRIANGULAR: + return _sample_triangular(params, generator) + if distribution_type is DistributionType.DISCRETE: + return _sample_discrete(params, generator) + raise DistributionConfigError( + f"Unsupported distribution type: {distribution_type}") + + +def _sample_normal(params: Mapping[str, Any], generator: Generator) -> float: + if "std_dev" not in params: + raise DistributionConfigError("normal distribution requires 'std_dev'") + std_dev = float(params["std_dev"]) + if std_dev < 0: + raise DistributionConfigError("std_dev must be non-negative") + mean = float(params.get("mean", 0.0)) + if std_dev == 0: + return mean + return float(generator.normal(loc=mean, scale=std_dev)) + + +def _sample_lognormal(params: Mapping[str, Any], generator: Generator) -> float: + if "sigma" not in params: + raise DistributionConfigError( + "lognormal distribution requires 'sigma'") + sigma = float(params["sigma"]) + if sigma < 0: + raise DistributionConfigError("sigma must be non-negative") + if "mean" not in params: + raise DistributionConfigError( + "lognormal distribution requires 'mean' (mu in log space)") + mean = float(params["mean"]) + return float(generator.lognormal(mean=mean, sigma=sigma)) + + +def _sample_triangular(params: Mapping[str, Any], generator: Generator) -> float: + required = {"min", "mode", "max"} + if not required.issubset(params): + missing = ", ".join(sorted(required - params.keys())) + raise DistributionConfigError( + f"triangular distribution missing parameters: {missing}") + left = float(params["min"]) + mode = float(params["mode"]) + right = float(params["max"]) + if not (left <= mode <= right): + raise DistributionConfigError( + "triangular distribution requires min <= mode <= max") + if left == right: + return mode + return float(generator.triangular(left=left, mode=mode, right=right)) + + +def _sample_discrete(params: Mapping[str, Any], generator: Generator) -> float: + values = params.get("values") + probabilities = params.get("probabilities") + if not isinstance(values, Sequence) or not isinstance(probabilities, Sequence): + raise DistributionConfigError( + "discrete distribution requires 'values' and 'probabilities' sequences") + if len(values) != len(probabilities) or not values: + raise DistributionConfigError( + "values and probabilities must be non-empty and of equal length") + probs = np.array(probabilities, dtype=float) + if np.any(probs < 0): + raise DistributionConfigError("probabilities must be non-negative") + total = probs.sum() + if not np.isclose(total, 1.0): + raise DistributionConfigError("probabilities must sum to 1.0") + probs = probs / total + choices = np.array(values, dtype=float) + return float(generator.choice(choices, p=probs)) + + +def _summarise(values: np.ndarray, percentiles: Sequence[float]) -> MetricSummary: + clean = values[~np.isnan(values)] + sample_size = clean.size + failed_runs = values.size - sample_size + + if sample_size == 0: + percentile_map: Dict[float, float] = { + pct: float("nan") for pct in percentiles} + return MetricSummary( + mean=float("nan"), + std_dev=float("nan"), + minimum=float("nan"), + maximum=float("nan"), + percentiles=percentile_map, + sample_size=0, + failed_runs=failed_runs, + ) + + percentile_map = { + pct: float(np.percentile(clean, pct)) for pct in percentiles + } + return MetricSummary( + mean=float(np.mean(clean)), + std_dev=float(np.std(clean, ddof=1)) if sample_size > 1 else 0.0, + minimum=float(np.min(clean)), + maximum=float(np.max(clean)), + percentiles=percentile_map, + sample_size=sample_size, + failed_runs=failed_runs, + ) diff --git a/services/unit_of_work.py b/services/unit_of_work.py index a51849e..d8579c7 100644 --- a/services/unit_of_work.py +++ b/services/unit_of_work.py @@ -6,16 +6,21 @@ from typing import Callable, Sequence from sqlalchemy.orm import Session from config.database import SessionLocal -from models import Role, Scenario +from models import PricingSettings, Project, Role, Scenario +from services.pricing import PricingMetadata from services.repositories import ( FinancialInputRepository, + PricingSettingsRepository, + PricingSettingsSeedResult, ProjectRepository, RoleRepository, ScenarioRepository, SimulationParameterRepository, UserRepository, ensure_admin_user as ensure_admin_user_record, + ensure_default_pricing_settings, ensure_default_roles, + pricing_settings_to_metadata, ) from services.scenario_validation import ScenarioComparisonValidator @@ -33,6 +38,7 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]): self.simulation_parameters: SimulationParameterRepository | None = None self.users: UserRepository | None = None self.roles: RoleRepository | None = None + self.pricing_settings: PricingSettingsRepository | None = None def __enter__(self) -> "UnitOfWork": self.session = self._session_factory() @@ -43,6 +49,7 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]): self.session) self.users = UserRepository(self.session) self.roles = RoleRepository(self.session) + self.pricing_settings = PricingSettingsRepository(self.session) self._scenario_validator = ScenarioComparisonValidator() return self @@ -60,6 +67,7 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]): self.simulation_parameters = None self.users = None self.roles = None + self.pricing_settings = None def flush(self) -> None: if not self.session: @@ -116,3 +124,45 @@ class UnitOfWork(AbstractContextManager["UnitOfWork"]): username=username, password=password, ) + + def ensure_default_pricing_settings( + self, + *, + metadata: PricingMetadata, + slug: str = "default", + name: str | None = None, + description: str | None = None, + ) -> PricingSettingsSeedResult: + if not self.pricing_settings: + raise RuntimeError("UnitOfWork session is not initialised") + return ensure_default_pricing_settings( + self.pricing_settings, + metadata=metadata, + slug=slug, + name=name, + description=description, + ) + + def get_pricing_metadata( + self, + *, + slug: str = "default", + ) -> PricingMetadata | None: + if not self.pricing_settings: + raise RuntimeError("UnitOfWork session is not initialised") + settings = self.pricing_settings.find_by_slug( + slug, + include_children=True, + ) + if settings is None: + return None + return pricing_settings_to_metadata(settings) + + def set_project_pricing_settings( + self, + project: Project, + pricing_settings: PricingSettings | None, + ) -> Project: + if not self.projects: + raise RuntimeError("UnitOfWork session is not initialised") + return self.projects.set_pricing_settings(project, pricing_settings) diff --git a/static/css/main.css b/static/css/main.css index 7848704..7ac28d2 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -1,3 +1,204 @@ +.report-overview { + margin-bottom: 2.5rem; +} + +.report-grid { + display: grid; + gap: 1.5rem; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.report-card { + background: var(--card); + border-radius: var(--radius); + padding: 1.5rem; + border: 1px solid var(--color-border); + box-shadow: 0 12px 30px rgba(4, 7, 14, 0.35); +} + +.report-card h2 { + margin-top: 0; + margin-bottom: 1rem; +} + +.report-section + .report-section { + margin-top: 3rem; +} + +.section-header { + margin-bottom: 1.25rem; +} + +.section-header h2 { + margin: 0; +} + +.section-subtitle { + margin: 0.35rem 0 0; + color: var(--muted); +} + +.metric-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.metric-list.compact { + gap: 0.35rem; +} + +.metric-list li { + display: flex; + justify-content: space-between; + align-items: baseline; + font-size: 0.95rem; + color: var(--muted); +} + +.metric-list strong { + font-size: 1.05rem; + color: var(--text); +} + +.metrics-table { + width: 100%; + border-collapse: collapse; + background: rgba(21, 27, 35, 0.6); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.metrics-table th, +.metrics-table td { + padding: 0.65rem 0.9rem; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.metrics-table th { + font-weight: 600; + color: var(--text); +} + +.metrics-table tr:last-child td, +.metrics-table tr:last-child th { + border-bottom: none; +} + +.definition-list { + margin: 0; + display: grid; + gap: 0.75rem; +} + +.definition-list div { + display: grid; + grid-template-columns: 140px 1fr; + gap: 0.5rem; + align-items: baseline; +} + +.definition-list dt { + color: var(--muted); + font-weight: 600; +} + +.definition-list dd { + margin: 0; +} + +.scenario-card { + background: var(--card); + border-radius: var(--radius); + padding: 1.5rem; + border: 1px solid var(--color-border); + box-shadow: 0 16px 32px rgba(4, 7, 14, 0.42); + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.scenario-card + .scenario-card { + margin-top: 1.75rem; +} + +.scenario-card-header { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: flex-start; +} + +.scenario-card h3 { + margin: 0; +} + +.scenario-meta { + text-align: right; +} + +.meta-label { + display: block; + color: var(--muted); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.meta-value { + font-weight: 600; +} + +.scenario-grid { + display: grid; + gap: 1.25rem; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +.scenario-panel { + background: rgba(15, 20, 27, 0.8); + border-radius: var(--radius-sm); + padding: 1.1rem; + border: 1px solid rgba(255, 255, 255, 0.05); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.scenario-panel h4, +.scenario-panel h5 { + margin-top: 0; + margin-bottom: 0.75rem; +} + +.note-list { + padding-left: 1.1rem; + color: var(--muted); + font-size: 0.9rem; +} + +.muted { + color: var(--muted); +} + +.page-actions .button { + text-decoration: none; + background: transparent; + border: 1px solid var(--color-border); + padding: 0.6rem 1rem; + border-radius: var(--radius-sm); + color: var(--text); + font-weight: 600; + transition: background 0.2s ease, border-color 0.2s ease; +} + +.page-actions .button:hover, +.page-actions .button:focus { + background: rgba(241, 178, 26, 0.14); + border-color: var(--brand); +} :root { --bg: #0b0f14; --bg-2: #0f141b; diff --git a/templates/partials/reports/filters_card.html b/templates/partials/reports/filters_card.html new file mode 100644 index 0000000..238393d --- /dev/null +++ b/templates/partials/reports/filters_card.html @@ -0,0 +1,33 @@ +{% if filters %} +
+
+

Active Filters

+
+ {% if filters.scenario_ids %} +
+
Scenario IDs
+
{{ filters.scenario_ids | join(', ') }}
+
+ {% endif %} + {% if filters.start_date %} +
+
Start Date
+
{{ filters.start_date }}
+
+ {% endif %} + {% if filters.end_date %} +
+
End Date
+
{{ filters.end_date }}
+
+ {% endif %} + {% if not (filters.scenario_ids or filters.start_date or filters.end_date) %} +
+
Status
+
No filters applied
+
+ {% endif %} +
+
+
+{% endif %} diff --git a/templates/partials/reports/monte_carlo_table.html b/templates/partials/reports/monte_carlo_table.html new file mode 100644 index 0000000..7388104 --- /dev/null +++ b/templates/partials/reports/monte_carlo_table.html @@ -0,0 +1,46 @@ +{% set sorted_metrics = metrics | dictsort %} +{% set ns = namespace(percentile_keys=[]) %} +{% if percentiles %} + {% set ns.percentile_keys = percentiles %} +{% elif sorted_metrics %} + {% set reference_percentiles = sorted_metrics[0][1].percentiles.keys() | list %} + {% set ns.percentile_keys = reference_percentiles %} +{% endif %} + +{% if sorted_metrics %} + + + + + + {% for percentile in ns.percentile_keys %} + {% set percentile_label = '%g' % percentile %} + + {% endfor %} + + + + + {% for metric_name, summary in sorted_metrics %} + + + + {% for percentile in ns.percentile_keys %} + {% set percentile_key = '%g' % percentile %} + {% set percentile_value = summary.percentiles.get(percentile_key) %} + + {% endfor %} + + + {% endfor %} + +
MetricMeanP{{ percentile_label }}Std Dev
{{ metric_name | replace('_', ' ') | title }}{{ summary.mean | format_metric(metric_name, currency) }} + {% if percentile_value is not none %} + {{ percentile_value | format_metric(metric_name, currency) }} + {% else %} + — + {% endif %} + {{ summary.std_dev | format_metric(metric_name, currency) }}
+{% else %} +

Monte Carlo metrics are unavailable.

+{% endif %} diff --git a/templates/partials/reports/options_card.html b/templates/partials/reports/options_card.html new file mode 100644 index 0000000..dc192f5 --- /dev/null +++ b/templates/partials/reports/options_card.html @@ -0,0 +1,56 @@ +{% if options %} + {% set distribution_enabled = options.distribution %} + {% set samples_enabled = options.samples and options.distribution %} +{% endif %} + +
+
+

Data Options

+
    +
  • + Monte Carlo Distribution + + {% if options %} + {{ distribution_enabled and "Enabled" or "Disabled" }} + {% else %} + Not requested + {% endif %} + +
  • +
  • + Sample Storage + + {% if options %} + {% if options.samples %} + {% if samples_enabled %} + Enabled + {% else %} + Requires distribution + {% endif %} + {% else %} + Disabled + {% endif %} + {% else %} + Not requested + {% endif %} + +
  • +
  • + Iterations + {{ iterations }} +
  • +
  • + Percentiles + + {% if percentiles %} + {% for percentile in percentiles %} + {{ '%g' % percentile }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% else %} + Defaults + {% endif %} + +
  • +
+
+
diff --git a/templates/partials/reports/scenario_actions.html b/templates/partials/reports/scenario_actions.html new file mode 100644 index 0000000..823ea6d --- /dev/null +++ b/templates/partials/reports/scenario_actions.html @@ -0,0 +1,14 @@ + diff --git a/templates/partials/reports_header.html b/templates/partials/reports_header.html new file mode 100644 index 0000000..c12e731 --- /dev/null +++ b/templates/partials/reports_header.html @@ -0,0 +1,24 @@ + diff --git a/templates/reports/project_summary.html b/templates/reports/project_summary.html new file mode 100644 index 0000000..f036240 --- /dev/null +++ b/templates/reports/project_summary.html @@ -0,0 +1,205 @@ +{% extends "base.html" %} +{% block title %}Project Summary | CalMiner{% endblock %} + +{% block content %} + {% include "partials/reports_header.html" with context %} + + {% include "partials/reports/options_card.html" with options=include_options iterations=iterations percentiles=percentiles %} + {% include "partials/reports/filters_card.html" with filters=filters %} + +
+
+
+

Project Details

+
+
+
Name
+
{{ project.name }}
+
+
+
Location
+
{{ project.location or "—" }}
+
+
+
Operation Type
+
{{ project.operation_type | replace("_", " ") | title }}
+
+
+
Scenarios
+
{{ scenario_count }}
+
+
+
Created
+
{{ project.created_at | format_datetime }}
+
+
+
Updated
+
{{ project.updated_at | format_datetime }}
+
+
+
+ +
+

Financial Summary

+
    +
  • + Total Inflows + {{ aggregates.financials.total_inflows | currency_display(project.currency) }} +
  • +
  • + Total Outflows + {{ aggregates.financials.total_outflows | currency_display(project.currency) }} +
  • +
  • + Net Cash Flow + {{ aggregates.financials.total_net | currency_display(project.currency) }} +
  • +
+
+ +
+

Deterministic Metrics

+ {% if aggregates.deterministic_metrics %} + + + + + + + + + + + {% for key, metric in aggregates.deterministic_metrics.items() %} + + + + + + + {% endfor %} + +
MetricAverageBestWorst
{{ key | replace("_", " ") | title }}{{ metric.average | format_metric(key, project.currency) }}{{ metric.maximum | format_metric(key, project.currency) }}{{ metric.minimum | format_metric(key, project.currency) }}
+ {% else %} +

Deterministic metrics are unavailable for the current filters.

+ {% endif %} +
+
+
+ +
+
+

Scenario Breakdown

+

Deterministic metrics and Monte Carlo summaries for each scenario.

+
+ + {% if scenarios %} + {% for item in scenarios %} +
+
+
+

{{ item.scenario.name }}

+

{{ item.scenario.status | title }} · {{ item.scenario.primary_resource or "No primary resource" }}

+
+
+ Currency + {{ item.scenario.currency or project.currency or "—" }} +
+ {% include "partials/reports/scenario_actions.html" with scenario=item.scenario %} +
+ +
+
+

Financial Totals

+
    +
  • + Inflows + {{ item.financials.inflows | currency_display(item.scenario.currency or project.currency) }} +
  • +
  • + Outflows + {{ item.financials.outflows | currency_display(item.scenario.currency or project.currency) }} +
  • +
  • + Net + {{ item.financials.net | currency_display(item.scenario.currency or project.currency) }} +
  • +
+
By Category
+ {% if item.financials.by_category %} +
    + {% for label, value in item.financials.by_category.items() %} +
  • + {{ label | replace("_", " ") | title }} + {{ value | currency_display(item.scenario.currency or project.currency) }} +
  • + {% endfor %} +
+ {% else %} +

No financial inputs recorded.

+ {% endif %} +
+ +
+

Deterministic Metrics

+ + + + + + + + + + + + + + + + + + + +
Discount Rate{{ item.metrics.discount_rate | percentage_display }}
NPV{{ item.metrics.npv | currency_display(item.scenario.currency or project.currency) }}
IRR{{ item.metrics.irr | percentage_display }}
Payback Period{{ item.metrics.payback_period | period_display }}
+ {% if item.metrics.notes %} +
    + {% for note in item.metrics.notes %} +
  • {{ note }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+

Monte Carlo Summary

+ {% if item.monte_carlo and item.monte_carlo.available %} +

+ Iterations: {{ item.monte_carlo.iterations }} + {% if percentiles %} + · Percentiles: + {% for percentile in percentiles %} + {{ '%g' % percentile }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% endif %} +

+ {% include "partials/reports/monte_carlo_table.html" with metrics=item.monte_carlo.metrics currency=item.scenario.currency or project.currency percentiles=percentiles %} + {% else %} +

Monte Carlo metrics are unavailable for this scenario.

+ {% if item.monte_carlo and item.monte_carlo.notes %} +
    + {% for note in item.monte_carlo.notes %} +
  • {{ note }}
  • + {% endfor %} +
+ {% endif %} + {% endif %} +
+
+
+ {% endfor %} + {% else %} +

No scenarios match the current filters.

+ {% endif %} +
+{% endblock %} diff --git a/templates/reports/scenario_comparison.html b/templates/reports/scenario_comparison.html new file mode 100644 index 0000000..86b0b79 --- /dev/null +++ b/templates/reports/scenario_comparison.html @@ -0,0 +1,166 @@ +{% extends "base.html" %} +{% block title %}Scenario Comparison | CalMiner{% endblock %} + +{% block content %} + {% include "partials/reports_header.html" with context %} + + {% include "partials/reports/options_card.html" with options=include_options iterations=iterations percentiles=percentiles %} +
+
+

Compared Scenarios

+
    + {% for item in scenarios %} +
  • + {{ item.scenario.name }} + #{{ item.scenario.id }} +
  • + {% endfor %} +
+
+
+ +
+
+

Project Details

+
+
+
Name
+
{{ project.name }}
+
+
+
Location
+
{{ project.location or "—" }}
+
+
+
Operation Type
+
{{ project.operation_type | replace("_", " ") | title }}
+
+
+
Scenarios Compared
+
{{ scenarios | length }}
+
+
+
+ +
+

Comparison Summary

+ {% if comparison %} + + + + + + + + + + + + {% for key, metric in comparison.items() %} + + + + + + + + {% endfor %} + +
MetricDirectionBest PerformerWorst PerformerAverage
{{ key | replace("_", " ") | title }}{{ metric.direction | replace("_", " ") | title }} + {% if metric.best %} + {{ metric.best.name }} + ({{ metric.best.value | format_metric(key, project.currency) }}) + {% else %} + — + {% endif %} + + {% if metric.worst %} + {{ metric.worst.name }} + ({{ metric.worst.value | format_metric(key, project.currency) }}) + {% else %} + — + {% endif %} + {{ metric.average | format_metric(key, project.currency) }}
+ {% else %} +

No deterministic metrics available for comparison.

+ {% endif %} +
+
+ +
+
+

Scenario Details

+

Each scenario includes deterministic metrics and Monte Carlo summaries.

+
+ + {% for item in scenarios %} +
+
+
+

{{ item.scenario.name }}

+

{{ item.scenario.status | title }} · Currency: {{ item.scenario.currency or project.currency }}

+
+
+ Primary Resource + {{ item.scenario.primary_resource or "—" }} +
+ {% include "partials/reports/scenario_actions.html" with scenario=item.scenario %} +
+ +
+
+

Deterministic Metrics

+ + + + + + + + + + + + + + + +
NPV{{ item.metrics.npv | currency_display(item.scenario.currency or project.currency) }}
IRR{{ item.metrics.irr | percentage_display }}
Payback Period{{ item.metrics.payback_period | period_display }}
+ {% if item.metrics.notes %} +
    + {% for note in item.metrics.notes %} +
  • {{ note }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+

Monte Carlo Summary

+ {% if item.monte_carlo and item.monte_carlo.available %} +

+ Iterations: {{ item.monte_carlo.iterations }} + {% if percentiles %} + · Percentiles: + {% for percentile in percentiles %} + {{ '%g' % percentile }}{% if not loop.last %}, {% endif %} + {% endfor %} + {% endif %} +

+ {% include "partials/reports/monte_carlo_table.html" with metrics=item.monte_carlo.metrics currency=item.scenario.currency or project.currency percentiles=percentiles %} + {% else %} +

No Monte Carlo data available for this scenario.

+ {% if item.monte_carlo and item.monte_carlo.notes %} +
    + {% for note in item.monte_carlo.notes %} +
  • {{ note }}
  • + {% endfor %} +
+ {% endif %} + {% endif %} +
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/templates/reports/scenario_distribution.html b/templates/reports/scenario_distribution.html new file mode 100644 index 0000000..f47e94a --- /dev/null +++ b/templates/reports/scenario_distribution.html @@ -0,0 +1,149 @@ +{% extends "base.html" %} +{% block title %}Scenario Distribution | CalMiner{% endblock %} + +{% block content %} + {% include "partials/reports_header.html" with context %} + +
+
+
+

Scenario Details

+
+
+
Name
+
{{ scenario.name }}
+
+
+
Project ID
+
{{ scenario.project_id }}
+
+
+
Status
+
{{ scenario.status | title }}
+
+
+
Currency
+
{{ scenario.currency or "—" }}
+
+
+
Discount Rate
+
{{ metrics.discount_rate | percentage_display }}
+
+
+
Updated
+
{{ scenario.updated_at | format_datetime }}
+
+
+
+ +
+

Financial Totals

+
    +
  • + Inflows + {{ summary.inflows | currency_display(scenario.currency) }} +
  • +
  • + Outflows + {{ summary.outflows | currency_display(scenario.currency) }} +
  • +
  • + Net Cash Flow + {{ summary.net | currency_display(scenario.currency) }} +
  • +
+ {% if summary.by_category %} +

By Category

+
    + {% for label, value in summary.by_category.items() %} +
  • + {{ label | replace("_", " ") | title }} + {{ value | currency_display(scenario.currency) }} +
  • + {% endfor %} +
+ {% endif %} +
+
+
+ +
+
+

Deterministic Metrics

+

Key financial indicators calculated from deterministic cash flows.

+
+ + + + + + + + + + + + + + + +
NPV{{ metrics.npv | currency_display(scenario.currency) }}
IRR{{ metrics.irr | percentage_display }}
Payback Period{{ metrics.payback_period | period_display }}
+ {% if metrics.notes %} +
    + {% for note in metrics.notes %} +
  • {{ note }}
  • + {% endfor %} +
+ {% endif %} +
+ +
+
+

Monte Carlo Distribution

+

Simulation-driven distributions contextualize stochastic variability.

+
+ {% if monte_carlo and monte_carlo.available %} +
+

Iterations: {{ monte_carlo.iterations }} · Percentiles: {{ percentiles | join(", ") }}

+ + + + + + + + + + + + {% for metric, summary in monte_carlo.metrics.items() %} + + + + + + + + {% endfor %} + +
MetricMeanP5MedianP95
{{ metric | replace("_", " ") | title }}{{ summary.mean | format_metric(metric, scenario.currency) }}{{ summary.percentiles['5'] | format_metric(metric, scenario.currency) }}{{ summary.percentiles['50'] | format_metric(metric, scenario.currency) }}{{ summary.percentiles['95'] | format_metric(metric, scenario.currency) }}
+ {% if monte_carlo.notes %} +
    + {% for note in monte_carlo.notes %} +
  • {{ note }}
  • + {% endfor %} +
+ {% endif %} +
+ {% else %} +

Monte Carlo output is unavailable for this scenario.

+ {% if monte_carlo and monte_carlo.notes %} +
    + {% for note in monte_carlo.notes %} +
  • {{ note }}
  • + {% endfor %} +
+ {% endif %} + {% endif %} +
+{% endblock %} diff --git a/templates/scenarios/form.html b/templates/scenarios/form.html index 0ddea13..7318220 100644 --- a/templates/scenarios/form.html +++ b/templates/scenarios/form.html @@ -49,7 +49,11 @@
- + {% set currency_prefill = scenario.currency if scenario and scenario.currency else default_currency %} + + {% if default_currency %} +

Defaults to {{ default_currency }} when left blank.

+ {% endif %}
diff --git a/tests/integration/test_scenario_lifecycle.py b/tests/integration/test_scenario_lifecycle.py index 3315c8b..f2c5a37 100644 --- a/tests/integration/test_scenario_lifecycle.py +++ b/tests/integration/test_scenario_lifecycle.py @@ -78,10 +78,8 @@ class TestScenarioLifecycle: json={"currency": "ca"}, ) assert invalid_update.status_code == 422 - assert ( - invalid_update.json()["detail"][0]["msg"] - == "Value error, Currency code must be a 3-letter ISO value" - ) + assert "Invalid currency code" in invalid_update.json()[ + "detail"][0]["msg"] # Scenario detail should still show the previous (valid) currency scenario_detail = client.get(f"/scenarios/{scenario_id}/view") diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 676ee42..e0aa898 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -10,7 +10,15 @@ from sqlalchemy.orm import Session, sessionmaker from config.database import Base from config.settings import AdminBootstrapSettings -from services.bootstrap import AdminBootstrapResult, RoleBootstrapResult, bootstrap_admin +from models import MiningOperationType, Project +from services.bootstrap import ( + AdminBootstrapResult, + PricingBootstrapResult, + RoleBootstrapResult, + bootstrap_admin, + bootstrap_pricing_settings, +) +from services.pricing import PricingMetadata from services.unit_of_work import UnitOfWork @@ -114,3 +122,86 @@ def test_bootstrap_respects_force_reset(unit_of_work_factory: Callable[[], UnitO user = users_repo.get_by_email(rotated_settings.email) assert user is not None assert user.verify_password("rotated") + + +def test_bootstrap_pricing_creates_defaults(unit_of_work_factory: Callable[[], UnitOfWork]) -> None: + metadata = PricingMetadata( + default_payable_pct=95.0, + default_currency="CAD", + moisture_threshold_pct=3.0, + moisture_penalty_per_pct=1.25, + ) + + result = bootstrap_pricing_settings( + metadata=metadata, + unit_of_work_factory=unit_of_work_factory, + ) + + assert isinstance(result, PricingBootstrapResult) + assert result.seed.created is True + assert result.projects_assigned == 0 + + with unit_of_work_factory() as uow: + settings_repo = uow.pricing_settings + assert settings_repo is not None + stored = settings_repo.get_by_slug("default") + assert stored.default_currency == "CAD" + assert float(stored.default_payable_pct) == pytest.approx(95.0) + assert float(stored.moisture_threshold_pct) == pytest.approx(3.0) + assert float(stored.moisture_penalty_per_pct) == pytest.approx(1.25) + + +def test_bootstrap_pricing_assigns_projects(unit_of_work_factory: Callable[[], UnitOfWork]) -> None: + metadata = PricingMetadata( + default_payable_pct=90.0, + default_currency="USD", + moisture_threshold_pct=5.0, + moisture_penalty_per_pct=0.5, + ) + + with unit_of_work_factory() as uow: + projects_repo = uow.projects + assert projects_repo is not None + project = Project( + name="Project Alpha", + operation_type=MiningOperationType.OPEN_PIT, + ) + created = projects_repo.create(project) + project_id = created.id + + result = bootstrap_pricing_settings( + metadata=metadata, + unit_of_work_factory=unit_of_work_factory, + ) + + assert result.projects_assigned == 1 + assert result.seed.created is True + + with unit_of_work_factory() as uow: + projects_repo = uow.projects + assert projects_repo is not None + stored = projects_repo.get(project_id, with_pricing=True) + assert stored.pricing_settings is not None + assert stored.pricing_settings.default_currency == "USD" + + +def test_bootstrap_pricing_is_idempotent(unit_of_work_factory: Callable[[], UnitOfWork]) -> None: + metadata = PricingMetadata( + default_payable_pct=92.5, + default_currency="EUR", + moisture_threshold_pct=4.5, + moisture_penalty_per_pct=0.75, + ) + + first = bootstrap_pricing_settings( + metadata=metadata, + unit_of_work_factory=unit_of_work_factory, + ) + second = bootstrap_pricing_settings( + metadata=metadata, + unit_of_work_factory=unit_of_work_factory, + ) + + assert first.seed.created is True + assert second.seed.created is False + assert second.projects_assigned == 0 diff --git a/tests/test_currency.py b/tests/test_currency.py new file mode 100644 index 0000000..f0ae13b --- /dev/null +++ b/tests/test_currency.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import pytest + +from services.currency import CurrencyValidationError, normalise_currency, require_currency + + +@pytest.mark.parametrize( + "raw,expected", + [ + ("usd", "USD"), + (" Eur ", "EUR"), + ("JPY", "JPY"), + (None, None), + ], +) +def test_normalise_currency_valid_inputs(raw: str | None, expected: str | None) -> None: + assert normalise_currency(raw) == expected + + +@pytest.mark.parametrize("raw", ["usd1", "us", "", "12", "X Y Z"]) +def test_normalise_currency_invalid_inputs(raw: str) -> None: + with pytest.raises(CurrencyValidationError): + normalise_currency(raw) + + +def test_require_currency_with_value() -> None: + assert require_currency("gbp", default="usd") == "GBP" + + +def test_require_currency_with_default() -> None: + assert require_currency(None, default="cad") == "CAD" + + +def test_require_currency_missing_default() -> None: + with pytest.raises(CurrencyValidationError): + require_currency(None) + + +def test_require_currency_invalid_default() -> None: + with pytest.raises(CurrencyValidationError): + require_currency(None, default="invalid") diff --git a/tests/test_export_routes.py b/tests/test_export_routes.py index e103b13..9d3ee46 100644 --- a/tests/test_export_routes.py +++ b/tests/test_export_routes.py @@ -128,3 +128,17 @@ def test_scenario_export_excel(client: TestClient, unit_of_work_factory) -> None with ZipFile(BytesIO(response.content)) as archive: assert "xl/workbook.xml" in archive.namelist() + + +def test_scenario_export_rejects_invalid_currency_filter(client: TestClient) -> None: + response = client.post( + "/exports/scenarios", + json={ + "format": "csv", + "filters": {"currencies": ["USD", "XX"]}, + }, + ) + + assert response.status_code == 422 + detail = response.json()["detail"] + assert "Invalid currency code" in detail diff --git a/tests/test_financial.py b/tests/test_financial.py new file mode 100644 index 0000000..73879e2 --- /dev/null +++ b/tests/test_financial.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from datetime import date + +import pytest + +from services.financial import ( + CashFlow, + PaybackNotReachedError, + internal_rate_of_return, + net_present_value, + normalize_cash_flows, + payback_period, +) + + +def test_normalize_cash_flows_with_dates() -> None: + base = date(2025, 1, 1) + period_length = 365.0 / 4 + flows = [ + CashFlow(amount=-1_000_000, date=base), + CashFlow(amount=350_000, date=date(2025, 4, 1)), + CashFlow(amount=420_000, date=date(2025, 7, 1)), + ] + + normalised = normalize_cash_flows(flows, compounds_per_year=4) + + assert normalised[0] == (-1_000_000.0, 0.0) + expected_second = (date(2025, 4, 1) - base).days / period_length + expected_third = (date(2025, 7, 1) - base).days / period_length + + assert normalised[1][1] == pytest.approx(expected_second, rel=1e-6) + assert normalised[2][1] == pytest.approx(expected_third, rel=1e-6) + + +def test_net_present_value_with_period_indices() -> None: + rate = 0.10 + flows = [ + CashFlow(amount=-1_000, period_index=0), + CashFlow(amount=500, period_index=1), + CashFlow(amount=500, period_index=2), + CashFlow(amount=500, period_index=3), + ] + + expected = -1_000 + sum(500 / (1 + rate) ** + period for period in range(1, 4)) + + result = net_present_value(rate, flows) + + assert result == pytest.approx(expected, rel=1e-9) + + +def test_net_present_value_with_residual_value() -> None: + rate = 0.08 + flows = [ + CashFlow(amount=-100_000, period_index=0), + CashFlow(amount=30_000, period_index=1), + CashFlow(amount=35_000, period_index=2), + ] + + expected = ( + -100_000 + + 30_000 / (1 + rate) + + 35_000 / (1 + rate) ** 2 + + 25_000 / (1 + rate) ** 3 + ) + + result = net_present_value(rate, flows, residual_value=25_000) + + assert result == pytest.approx(expected, rel=1e-9) + + +def test_internal_rate_of_return_simple_case() -> None: + flows = [ + CashFlow(amount=-1_000, period_index=0), + CashFlow(amount=1_210, period_index=1), + ] + + irr = internal_rate_of_return(flows, guess=0.05) + + assert irr == pytest.approx(0.21, rel=1e-9) + + +def test_internal_rate_of_return_multiple_sign_changes() -> None: + flows = [ + CashFlow(amount=-500_000, period_index=0), + CashFlow(amount=250_000, period_index=1), + CashFlow(amount=-100_000, period_index=2), + CashFlow(amount=425_000, period_index=3), + ] + + irr = internal_rate_of_return(flows, guess=0.2) + + npv = net_present_value(irr, flows) + + assert npv == pytest.approx(0.0, abs=1e-6) + + +def test_internal_rate_of_return_requires_mixed_signs() -> None: + flows = [ + CashFlow(amount=100_000, period_index=0), + CashFlow(amount=150_000, period_index=1), + ] + + with pytest.raises(ValueError): + internal_rate_of_return(flows) + + +def test_payback_period_exact_period() -> None: + flows = [ + CashFlow(amount=-120_000, period_index=0), + CashFlow(amount=40_000, period_index=1), + CashFlow(amount=40_000, period_index=2), + CashFlow(amount=40_000, period_index=3), + ] + + period = payback_period(flows, allow_fractional=False) + + assert period == pytest.approx(3.0) + + +def test_payback_period_fractional_period() -> None: + flows = [ + CashFlow(amount=-100_000, period_index=0), + CashFlow(amount=80_000, period_index=1), + CashFlow(amount=30_000, period_index=2), + ] + + fractional = payback_period(flows) + whole = payback_period(flows, allow_fractional=False) + + assert fractional == pytest.approx(1 + 20_000 / 30_000, rel=1e-9) + assert whole == pytest.approx(2.0) + + +def test_payback_period_raises_when_never_recovered() -> None: + flows = [ + CashFlow(amount=-250_000, period_index=0), + CashFlow(amount=50_000, period_index=1), + CashFlow(amount=60_000, period_index=2), + CashFlow(amount=70_000, period_index=3), + ] + + with pytest.raises(PaybackNotReachedError): + payback_period(flows) + + +def test_payback_period_with_quarterly_compounding() -> None: + base = date(2025, 1, 1) + flows = [ + CashFlow(amount=-120_000, date=base), + CashFlow(amount=35_000, date=date(2025, 4, 1)), + CashFlow(amount=35_000, date=date(2025, 7, 1)), + CashFlow(amount=50_000, date=date(2025, 10, 1)), + ] + + period = payback_period(flows, compounds_per_year=4) + + period_length = 365.0 / 4 + expected_period = (date(2025, 10, 1) - base).days / period_length + + assert period == pytest.approx(expected_period, abs=1e-6) diff --git a/tests/test_import_api.py b/tests/test_import_api.py index 47ae9d9..58660df 100644 --- a/tests/test_import_api.py +++ b/tests/test_import_api.py @@ -68,3 +68,35 @@ def test_scenario_import_commit_invalid_token_returns_404( ) assert response.status_code == 404 assert "Unknown scenario import token" in response.json()["detail"] + + +def test_scenario_import_preview_rejects_invalid_currency( + client: TestClient, + unit_of_work_factory, +) -> None: + with unit_of_work_factory() as uow: + assert uow.projects is not None + project = Project( + name="Import Currency Project", + operation_type=MiningOperationType.OPEN_PIT, + ) + uow.projects.create(project) + + csv_content = ( + "project_name,name,currency\n" + "Import Currency Project,Invalid Currency,US\n" + ) + + response = client.post( + "/imports/scenarios/preview", + files={"file": ("scenarios.csv", csv_content, "text/csv")}, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["summary"]["accepted"] == 0 + assert payload["summary"]["errored"] == 1 + assert payload["parser_errors"] + parser_error = payload["parser_errors"][0] + assert parser_error["field"] == "currency" + assert "Invalid currency code" in parser_error["message"] diff --git a/tests/test_import_parsing.py b/tests/test_import_parsing.py index 2f08be7..d75a842 100644 --- a/tests/test_import_parsing.py +++ b/tests/test_import_parsing.py @@ -140,3 +140,22 @@ def test_scenario_import_handles_large_dataset() -> None: assert len(result.rows) == 500 assert len(result.rows) == 500 + + +def test_scenario_import_rejects_invalid_currency() -> None: + csv_content = dedent( + """ + project_name,name,currency + Project A,Scenario Invalid,US + """ + ).strip() + stream = BytesIO(csv_content.encode("utf-8")) + + result = load_scenario_imports(stream, "scenarios.csv") + + assert not result.rows + assert result.errors + error = result.errors[0] + assert error.row_number == 2 + assert error.field == "currency" + assert "Invalid currency code" in error.message diff --git a/tests/test_pricing.py b/tests/test_pricing.py new file mode 100644 index 0000000..f57af45 --- /dev/null +++ b/tests/test_pricing.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import math + +import pytest + +from services.pricing import ( + PricingInput, + PricingMetadata, + PricingResult, + calculate_pricing, +) + + +def test_calculate_pricing_with_explicit_penalties() -> None: + pricing_input = PricingInput( + metal="copper", + ore_tonnage=100_000, + head_grade_pct=1.2, + recovery_pct=90, + payable_pct=96, + reference_price=8_500, + treatment_charge=100_000, + smelting_charge=0, + moisture_pct=10, + moisture_threshold_pct=8, + moisture_penalty_per_pct=3_000, + impurity_ppm={"As": 100}, + impurity_thresholds={"As": 0}, + impurity_penalty_per_ppm={"As": 2}, + premiums=50_000, + fx_rate=1.0, + currency_code="usd", + ) + + result = calculate_pricing(pricing_input) + + assert isinstance(result, PricingResult) + assert math.isclose(result.payable_metal_tonnes, 1_036.8, rel_tol=1e-6) + assert math.isclose(result.gross_revenue, 1_036.8 * 8_500, rel_tol=1e-6) + assert math.isclose(result.moisture_penalty, 6_000, rel_tol=1e-6) + assert math.isclose(result.impurity_penalty, 200, rel_tol=1e-6) + assert math.isclose(result.net_revenue, 8_756_600, rel_tol=1e-6) + assert result.treatment_smelt_charges == pytest.approx(100_000) + assert result.currency == "USD" + + +def test_calculate_pricing_with_metadata_defaults() -> None: + metadata = PricingMetadata( + default_payable_pct=95, + default_currency="EUR", + moisture_threshold_pct=7, + moisture_penalty_per_pct=2_000, + impurity_thresholds={"Pb": 50}, + impurity_penalty_per_ppm={"Pb": 1.5}, + ) + + pricing_input = PricingInput( + metal="lead", + ore_tonnage=50_000, + head_grade_pct=5, + recovery_pct=85, + payable_pct=None, + reference_price=2_000, + treatment_charge=30_000, + smelting_charge=20_000, + moisture_pct=9, + moisture_threshold_pct=None, + moisture_penalty_per_pct=None, + impurity_ppm={"Pb": 120}, + premiums=12_000, + fx_rate=1.2, + currency_code=None, + ) + + result = calculate_pricing(pricing_input, metadata=metadata) + + expected_payable = 50_000 * 0.05 * 0.85 * 0.95 + assert math.isclose(result.payable_metal_tonnes, + expected_payable, rel_tol=1e-6) + assert result.moisture_penalty == pytest.approx((9 - 7) * 2_000) + assert result.impurity_penalty == pytest.approx((120 - 50) * 1.5) + assert result.treatment_smelt_charges == pytest.approx(50_000) + assert result.currency == "EUR" + assert result.net_revenue > 0 + + +def test_calculate_pricing_currency_override() -> None: + pricing_input = PricingInput( + metal="gold", + ore_tonnage=10_000, + head_grade_pct=2.5, + recovery_pct=92, + payable_pct=98, + reference_price=60_000, + treatment_charge=40_000, + smelting_charge=10_000, + moisture_pct=5, + moisture_threshold_pct=7, + moisture_penalty_per_pct=1_000, + premiums=25_000, + fx_rate=1.0, + currency_code="cad", + ) + + metadata = PricingMetadata(default_currency="USD") + + result = calculate_pricing( + pricing_input, metadata=metadata, currency="CAD") + + assert result.currency == "CAD" + assert result.net_revenue > 0 + + +def test_calculate_pricing_multiple_inputs_aggregate() -> None: + metadata = PricingMetadata(default_currency="USD") + inputs = [ + PricingInput( + metal="copper", + ore_tonnage=10_000, + head_grade_pct=1.5, + recovery_pct=88, + payable_pct=95, + reference_price=8_000, + treatment_charge=20_000, + smelting_charge=5_000, + moisture_pct=7, + moisture_threshold_pct=8, + moisture_penalty_per_pct=1_000, + premiums=0, + fx_rate=1.0, + currency_code=None, + ), + PricingInput( + metal="copper", + ore_tonnage=8_000, + head_grade_pct=1.1, + recovery_pct=90, + payable_pct=96, + reference_price=8_000, + treatment_charge=18_000, + smelting_charge=4_000, + moisture_pct=9, + moisture_threshold_pct=8, + moisture_penalty_per_pct=1_000, + premiums=0, + fx_rate=1.0, + currency_code="usd", + ), + ] + + results = [calculate_pricing(i, metadata=metadata) for i in inputs] + + assert all(result.currency == "USD" for result in results) + assert sum(result.net_revenue for result in results) > 0 diff --git a/tests/test_pricing_settings_repository.py b/tests/test_pricing_settings_repository.py new file mode 100644 index 0000000..1ac03e5 --- /dev/null +++ b/tests/test_pricing_settings_repository.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from config.database import Base +from models import PricingImpuritySettings, PricingMetalSettings, PricingSettings +from services.pricing import PricingMetadata +from services.repositories import ( + PricingSettingsRepository, + ensure_default_pricing_settings, +) +from services.unit_of_work import UnitOfWork + + +@pytest.fixture() +def engine() -> Iterator: + engine = create_engine("sqlite:///:memory:", future=True) + Base.metadata.create_all(bind=engine) + try: + yield engine + finally: + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def session(engine) -> Iterator[Session]: + TestingSession = sessionmaker( + bind=engine, expire_on_commit=False, future=True) + db = TestingSession() + try: + yield db + finally: + db.close() + + +def test_pricing_settings_repository_crud(session: Session) -> None: + repo = PricingSettingsRepository(session) + + settings = PricingSettings( + name="Contract A", + slug="Contract-A", + default_currency="usd", + default_payable_pct=95.0, + moisture_threshold_pct=7.5, + moisture_penalty_per_pct=1500.0, + ) + repo.create(settings) + + metal_override = PricingMetalSettings( + metal_code="Copper", + payable_pct=96.0, + moisture_threshold_pct=None, + moisture_penalty_per_pct=None, + ) + repo.attach_metal_override(settings, metal_override) + + impurity_override = PricingImpuritySettings( + impurity_code="as", + threshold_ppm=100.0, + penalty_per_ppm=3.5, + ) + repo.attach_impurity_override(settings, impurity_override) + + retrieved = repo.get_by_slug("CONTRACT-A", include_children=True) + assert retrieved.slug == "contract-a" + assert retrieved.default_currency == "USD" + assert len(retrieved.metal_overrides) == 1 + assert retrieved.metal_overrides[0].metal_code == "copper" + assert len(retrieved.impurity_overrides) == 1 + assert retrieved.impurity_overrides[0].impurity_code == "AS" + + listed = repo.list(include_children=True) + assert len(listed) == 1 + assert listed[0].id == settings.id + + +def test_ensure_default_pricing_settings_creates_and_updates(session: Session) -> None: + repo = PricingSettingsRepository(session) + + metadata_initial = PricingMetadata( + default_payable_pct=100.0, + default_currency="USD", + moisture_threshold_pct=8.0, + moisture_penalty_per_pct=0.0, + impurity_thresholds={"As": 50.0}, + impurity_penalty_per_ppm={"As": 2.0}, + ) + + result_create = ensure_default_pricing_settings( + repo, + metadata=metadata_initial, + name="Seeded Pricing", + description="Seeded from defaults", + ) + + assert result_create.created is True + assert result_create.settings.slug == "default" + assert result_create.settings.default_currency == "USD" + assert len(result_create.settings.impurity_overrides) == 1 + assert result_create.settings.impurity_overrides[0].penalty_per_ppm == 2.0 + + metadata_update = PricingMetadata( + default_payable_pct=97.0, + default_currency="EUR", + moisture_threshold_pct=6.5, + moisture_penalty_per_pct=250.0, + impurity_thresholds={"As": 45.0, "Pb": 12.0}, + impurity_penalty_per_ppm={"As": 3.0, "Pb": 1.25}, + ) + + result_update = ensure_default_pricing_settings( + repo, + metadata=metadata_update, + name="Seeded Pricing", + description="Seeded from defaults", + ) + + assert result_update.created is False + assert result_update.updated_fields > 0 + assert result_update.impurity_upserts >= 1 + + updated = repo.get_by_slug("default", include_children=True) + assert updated.default_currency == "EUR" + as_override = { + item.impurity_code: item for item in updated.impurity_overrides}["AS"] + assert float(as_override.threshold_ppm) == 45.0 + assert float(as_override.penalty_per_ppm) == 3.0 + pb_override = { + item.impurity_code: item for item in updated.impurity_overrides}["PB"] + assert float(pb_override.threshold_ppm) == 12.0 + + +def test_unit_of_work_exposes_pricing_settings(engine) -> None: + TestingSession = sessionmaker( + bind=engine, expire_on_commit=False, future=True) + metadata = PricingMetadata( + default_payable_pct=99.0, + default_currency="USD", + moisture_threshold_pct=7.0, + moisture_penalty_per_pct=125.0, + impurity_thresholds={"Zn": 80.0}, + impurity_penalty_per_ppm={"Zn": 0.5}, + ) + + with UnitOfWork(session_factory=TestingSession) as uow: + assert uow.pricing_settings is not None + result = uow.ensure_default_pricing_settings( + metadata=metadata, + slug="contract-core", + name="Contract Core", + ) + assert result.settings.slug == "contract-core" + assert result.created is True + + with UnitOfWork(session_factory=TestingSession) as uow: + assert uow.pricing_settings is not None + stored = uow.pricing_settings.get_by_slug( + "contract-core", include_children=True) + assert stored.default_payable_pct == 99.0 + assert stored.impurity_overrides[0].impurity_code == "ZN" + + +def test_unit_of_work_get_pricing_metadata_returns_defaults(engine) -> None: + TestingSession = sessionmaker( + bind=engine, expire_on_commit=False, future=True) + seeded_metadata = PricingMetadata( + default_payable_pct=96.5, + default_currency="aud", + moisture_threshold_pct=6.25, + moisture_penalty_per_pct=210.0, + impurity_thresholds={"As": 45.0, "Pb": 15.0}, + impurity_penalty_per_ppm={"As": 1.75, "Pb": 0.9}, + ) + + with UnitOfWork(session_factory=TestingSession) as uow: + result = uow.ensure_default_pricing_settings( + metadata=seeded_metadata, + slug="default", + name="Default Contract", + description="Primary contract defaults", + ) + assert result.created is True + + with UnitOfWork(session_factory=TestingSession) as uow: + retrieved = uow.get_pricing_metadata() + + assert retrieved is not None + assert retrieved.default_currency == "AUD" + assert retrieved.default_payable_pct == 96.5 + assert retrieved.moisture_threshold_pct == 6.25 + assert retrieved.moisture_penalty_per_pct == 210.0 + assert retrieved.impurity_thresholds["AS"] == 45.0 + assert retrieved.impurity_thresholds["PB"] == 15.0 + assert retrieved.impurity_penalty_per_ppm["AS"] == 1.75 + assert retrieved.impurity_penalty_per_ppm["PB"] == 0.9 + + +def test_unit_of_work_get_pricing_metadata_returns_none_when_missing(engine) -> None: + TestingSession = sessionmaker( + bind=engine, expire_on_commit=False, future=True) + + with UnitOfWork(session_factory=TestingSession) as uow: + missing = uow.get_pricing_metadata(slug="non-existent") + + assert missing is None diff --git a/tests/test_repositories.py b/tests/test_repositories.py index 1ea6eb9..168e84d 100644 --- a/tests/test_repositories.py +++ b/tests/test_repositories.py @@ -13,6 +13,7 @@ from models import ( FinancialCategory, FinancialInput, MiningOperationType, + PricingSettings, Project, Scenario, ScenarioStatus, @@ -147,6 +148,30 @@ def test_unit_of_work_commit_and_rollback(engine) -> None: projects = ProjectRepository(session).list() assert len(projects) == 1 + +def test_unit_of_work_set_project_pricing_settings(engine) -> None: + TestingSession = sessionmaker(bind=engine, expire_on_commit=False, future=True) + with UnitOfWork(session_factory=TestingSession) as uow: + assert uow.projects is not None and uow.pricing_settings is not None + project = Project(name="Project Pricing", operation_type=MiningOperationType.OTHER) + uow.projects.create(project) + pricing_settings = PricingSettings( + name="Default Pricing", + slug="default", + default_currency="usd", + default_payable_pct=100.0, + moisture_threshold_pct=8.0, + moisture_penalty_per_pct=0.0, + ) + uow.pricing_settings.create(pricing_settings) + uow.set_project_pricing_settings(project, pricing_settings) + + with TestingSession() as session: + repo = ProjectRepository(session) + stored = repo.get(1, with_pricing=True) + assert stored.pricing_settings is not None + assert stored.pricing_settings.slug == "default" + # Rollback path with pytest.raises(RuntimeError): with UnitOfWork(session_factory=TestingSession) as uow: @@ -302,6 +327,44 @@ def test_project_repository_filtered_for_export(session: Session) -> None: assert results[0].scenarios[0].name == "Alpha Scenario" +def test_project_repository_with_pricing_settings(session: Session) -> None: + repo = ProjectRepository(session) + settings = PricingSettings( + name="Contract Core", + slug="contract-core", + default_currency="usd", + default_payable_pct=95.0, + moisture_threshold_pct=7.5, + moisture_penalty_per_pct=100.0, + ) + project = Project( + name="Project Pricing", + operation_type=MiningOperationType.OPEN_PIT, + pricing_settings=settings, + ) + session.add(project) + session.flush() + + fetched = repo.get(project.id, with_pricing=True) + assert fetched.pricing_settings is not None + assert fetched.pricing_settings.slug == "contract-core" + assert fetched.pricing_settings.default_currency == "USD" + + listed = repo.list(with_pricing=True) + assert listed[0].pricing_settings is not None + + repo.set_pricing_settings(project, None) + session.refresh(project) + assert project.pricing_settings is None + + repo.set_pricing_settings(project, settings) + session.refresh(project) + assert project.pricing_settings is settings + + export_results = repo.filtered_for_export(None, include_pricing=True) + assert export_results[0].pricing_settings is not None + + def test_scenario_repository_filtered_for_export(session: Session) -> None: repo = ScenarioRepository(session) diff --git a/tests/test_scenario_pricing.py b/tests/test_scenario_pricing.py new file mode 100644 index 0000000..a9c11c4 --- /dev/null +++ b/tests/test_scenario_pricing.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import pytest + +from models import MiningOperationType, Project, Scenario, ScenarioStatus +from services.pricing import PricingInput, PricingMetadata +from services.scenario_evaluation import ScenarioPricingConfig, ScenarioPricingEvaluator + + +def build_scenario() -> Scenario: + project = Project(name="Test Project", + operation_type=MiningOperationType.OPEN_PIT) + scenario = Scenario( + project=project, + project_id=1, + name="Scenario A", + status=ScenarioStatus.ACTIVE, + currency="USD", + ) + scenario.id = 1 # simulate persisted entity + return scenario + + +def test_scenario_pricing_evaluator_uses_metadata_defaults() -> None: + scenario = build_scenario() + evaluator = ScenarioPricingEvaluator( + ScenarioPricingConfig( + metadata=PricingMetadata( + default_currency="USD", default_payable_pct=95) + ) + ) + inputs = [ + PricingInput( + metal="copper", + ore_tonnage=50_000, + head_grade_pct=1.0, + recovery_pct=90, + payable_pct=None, + reference_price=9_000, + treatment_charge=50_000, + smelting_charge=10_000, + moisture_pct=9, + moisture_threshold_pct=None, + moisture_penalty_per_pct=None, + impurity_ppm={"As": 120}, + premiums=10_000, + fx_rate=1.0, + currency_code=None, + ) + ] + + snapshot = evaluator.evaluate(scenario, inputs=inputs) + + assert snapshot.scenario_id == scenario.id + assert len(snapshot.results) == 1 + result = snapshot.results[0] + assert result.currency == "USD" + assert result.net_revenue > 0 + + +def test_scenario_pricing_evaluator_override_metadata() -> None: + scenario = build_scenario() + evaluator = ScenarioPricingEvaluator(ScenarioPricingConfig()) + metadata_override = PricingMetadata( + default_currency="CAD", + default_payable_pct=90, + moisture_threshold_pct=5, + moisture_penalty_per_pct=500, + ) + + inputs = [ + PricingInput( + metal="copper", + ore_tonnage=20_000, + head_grade_pct=1.2, + recovery_pct=88, + payable_pct=None, + reference_price=8_200, + treatment_charge=15_000, + smelting_charge=6_000, + moisture_pct=6, + moisture_threshold_pct=None, + moisture_penalty_per_pct=None, + premiums=5_000, + fx_rate=1.0, + currency_code="cad", + ), + PricingInput( + metal="gold", + ore_tonnage=5_000, + head_grade_pct=2.0, + recovery_pct=90, + payable_pct=None, + reference_price=60_000, + treatment_charge=10_000, + smelting_charge=5_000, + moisture_pct=4, + moisture_threshold_pct=None, + moisture_penalty_per_pct=None, + premiums=15_000, + fx_rate=1.0, + currency_code="cad", + ), + ] + + snapshot = evaluator.evaluate( + scenario, + inputs=inputs, + metadata_override=metadata_override, + ) + + assert len(snapshot.results) == 2 + assert all(result.currency == + scenario.currency for result in snapshot.results) + + copper_result = snapshot.results[0] + expected_payable = 20_000 * 0.012 * 0.88 * 0.90 + assert copper_result.payable_metal_tonnes == pytest.approx( + expected_payable) + assert sum(result.net_revenue for result in snapshot.results) > 0 diff --git a/tests/test_scenario_validation.py b/tests/test_scenario_validation.py index a9aaf07..b9a048b 100644 --- a/tests/test_scenario_validation.py +++ b/tests/test_scenario_validation.py @@ -278,3 +278,70 @@ class TestScenarioComparisonEndpoint: detail = response.json()["detail"] assert detail["code"] == "SCENARIO_PROJECT_MISMATCH" assert project_a_id != project_b_id + + +class TestScenarioApiCurrencyValidation: + def test_create_api_rejects_invalid_currency( + self, + api_client: TestClient, + session_factory: sessionmaker, + ) -> None: + with UnitOfWork(session_factory=session_factory) as uow: + assert uow.projects is not None + assert uow.scenarios is not None + project = Project( + name="Currency Validation Project", + operation_type=MiningOperationType.OPEN_PIT, + ) + uow.projects.create(project) + project_id = project.id + + response = api_client.post( + f"/projects/{project_id}/scenarios", + json={ + "name": "Invalid Currency Scenario", + "currency": "US", + }, + ) + + assert response.status_code == 422 + detail = response.json().get("detail", []) + assert any( + "Invalid currency code" in item.get("msg", "") for item in detail + ), detail + + with UnitOfWork(session_factory=session_factory) as uow: + assert uow.scenarios is not None + scenarios = uow.scenarios.list_for_project(project_id) + assert scenarios == [] + + def test_create_api_normalises_currency( + self, + api_client: TestClient, + session_factory: sessionmaker, + ) -> None: + with UnitOfWork(session_factory=session_factory) as uow: + assert uow.projects is not None + assert uow.scenarios is not None + project = Project( + name="Currency Normalisation Project", + operation_type=MiningOperationType.OPEN_PIT, + ) + uow.projects.create(project) + project_id = project.id + + response = api_client.post( + f"/projects/{project_id}/scenarios", + json={ + "name": "Normalised Currency Scenario", + "currency": "cad", + }, + ) + + assert response.status_code == 201 + + with UnitOfWork(session_factory=session_factory) as uow: + assert uow.scenarios is not None + scenarios = uow.scenarios.list_for_project(project_id) + assert len(scenarios) == 1 + assert scenarios[0].currency == "CAD" diff --git a/tests/test_simulation.py b/tests/test_simulation.py new file mode 100644 index 0000000..ebb7982 --- /dev/null +++ b/tests/test_simulation.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from services.financial import CashFlow, net_present_value +from services.simulation import ( + CashFlowSpec, + DistributionConfigError, + DistributionSource, + DistributionSpec, + DistributionType, + SimulationConfig, + SimulationMetric, + run_monte_carlo, +) + + +def test_run_monte_carlo_deterministic_matches_financial_helpers() -> None: + base_flows = [ + CashFlow(amount=-1000.0, period_index=0), + CashFlow(amount=600.0, period_index=1), + CashFlow(amount=600.0, period_index=2), + ] + specs = [CashFlowSpec(cash_flow=flow) for flow in base_flows] + config = SimulationConfig( + iterations=10, + discount_rate=0.1, + percentiles=(50,), + seed=123, + ) + + result = run_monte_carlo(specs, config) + summary = result.summaries[SimulationMetric.NPV] + expected = net_present_value(0.1, base_flows) + + assert summary.sample_size == config.iterations + assert summary.failed_runs == 0 + assert summary.mean == pytest.approx(expected, rel=1e-6) + assert summary.std_dev == 0.0 + assert summary.percentiles[50] == pytest.approx(expected, rel=1e-6) + + +def test_run_monte_carlo_normal_distribution_uses_seed_for_reproducibility() -> None: + base_flows = [ + CashFlow(amount=-100.0, period_index=0), + CashFlow(amount=0.0, period_index=1), + CashFlow(amount=0.0, period_index=2), + ] + revenue_flow = CashFlowSpec( + cash_flow=CashFlow(amount=120.0, period_index=1), + distribution=DistributionSpec( + type=DistributionType.NORMAL, + parameters={"mean": 120.0, "std_dev": 10.0}, + ), + ) + specs = [CashFlowSpec(cash_flow=base_flows[0]), revenue_flow] + config = SimulationConfig( + iterations=1000, + discount_rate=0.0, + percentiles=(5.0, 50.0, 95.0), + seed=42, + ) + + result = run_monte_carlo(specs, config) + summary = result.summaries[SimulationMetric.NPV] + + assert summary.sample_size == config.iterations + assert summary.failed_runs == 0 + # With zero discount rate the expected mean NPV equals mean sampled value minus investment. + assert summary.mean == pytest.approx(20.0, abs=1.0) + assert summary.std_dev == pytest.approx(10.0, abs=1.0) + assert summary.percentiles[50.0] == pytest.approx(summary.mean, abs=1.0) + + +def test_run_monte_carlo_supports_scenario_field_source() -> None: + base_flow = CashFlow(amount=0.0, period_index=1) + spec = CashFlowSpec( + cash_flow=base_flow, + distribution=DistributionSpec( + type=DistributionType.NORMAL, + parameters={"std_dev": 0.0}, + source=DistributionSource.SCENARIO_FIELD, + source_key="salvage_mean", + ), + ) + config = SimulationConfig(iterations=1, discount_rate=0.0, seed=7) + + result = run_monte_carlo( + [CashFlowSpec(cash_flow=CashFlow( + amount=-100.0, period_index=0)), spec], + config, + scenario_context={"salvage_mean": 150.0}, + ) + + summary = result.summaries[SimulationMetric.NPV] + assert summary.sample_size == 1 + assert summary.mean == pytest.approx(50.0) + + +def test_run_monte_carlo_records_failed_metrics_when_not_defined() -> None: + base_flows = [CashFlow(amount=100.0, period_index=0)] + specs = [CashFlowSpec(cash_flow=flow) for flow in base_flows] + config = SimulationConfig( + iterations=5, + discount_rate=0.1, + metrics=(SimulationMetric.IRR,), + seed=5, + ) + + result = run_monte_carlo(specs, config) + summary = result.summaries[SimulationMetric.IRR] + + assert summary.sample_size == 0 + assert summary.failed_runs == config.iterations + assert math.isnan(summary.mean) + + +def test_run_monte_carlo_distribution_missing_context_raises() -> None: + spec = DistributionSpec( + type=DistributionType.NORMAL, + parameters={"std_dev": 1.0}, + source=DistributionSource.SCENARIO_FIELD, + source_key="unknown", + ) + cash_flow_spec = CashFlowSpec( + cash_flow=CashFlow(amount=0.0, period_index=0), + distribution=spec, + ) + config = SimulationConfig(iterations=1, discount_rate=0.0) + + with pytest.raises(DistributionConfigError): + run_monte_carlo([cash_flow_spec], config, scenario_context={}) + + +def test_run_monte_carlo_can_return_samples() -> None: + base_flow = CashFlow(amount=50.0, period_index=1) + specs = [ + CashFlowSpec(cash_flow=CashFlow(amount=-40.0, period_index=0)), + CashFlowSpec(cash_flow=base_flow), + ] + config = SimulationConfig( + iterations=3, + discount_rate=0.0, + metrics=(SimulationMetric.NPV,), + return_samples=True, + seed=11, + ) + + result = run_monte_carlo(specs, config) + + assert result.samples is not None + assert SimulationMetric.NPV in result.samples + samples = result.samples[SimulationMetric.NPV] + assert isinstance(samples, np.ndarray) + assert samples.shape == (config.iterations,)