diff --git a/README.md b/README.md index 34802075..16921d08 100644 --- a/README.md +++ b/README.md @@ -164,9 +164,22 @@ Optional runtime overrides: - `--strict-policy warn|strict` (reconciliation fail policy override) - `--export-pdf` / `--no-export-pdf` -- `--formatting-profile ` (reserved selector for formatter policy wiring) +- `--formatting-profile ` (applies supported numeric formatting policies to output writers) - `--settings ` (Runner.xlsm / GUI serialized settings payload) +### Formatting Profiles + +Supported formatting profile values: + +- `default` - preserve template/default number formats. +- `currency` - USD-style currency formatting for historical notional outputs. +- `accounting` - accounting-style currency formatting for historical notional outputs. +- `plain` - non-currency numeric format with two decimals. + +Current profile application scope: + +- Historical workbook append rows written by the pipeline. + Legacy packaging path is still available for release validation only: - `counter-risk run --fixture-replay --config config/fixture_replay.yml --output-dir ` diff --git a/docs/operator_ux_decision.md b/docs/operator_ux_decision.md index c5ddffeb..fad887e4 100644 --- a/docs/operator_ux_decision.md +++ b/docs/operator_ux_decision.md @@ -38,7 +38,7 @@ This document defines the operator experience for running the monthly counterpar - `Input Root` - `Discovery Mode` (`discover` or manual mode) - `Strict Policy` (`warn` or `strict`) - - `Formatting Profile` + - `Formatting Profile` (`default`, `currency`, `accounting`, or `plain`) - `Output Root` 5. Click `Run Monthly Process`. 6. Review completion message and warnings panel. diff --git a/src/counter_risk/cli/__init__.py b/src/counter_risk/cli/__init__.py index 885db421..00193800 100644 --- a/src/counter_risk/cli/__init__.py +++ b/src/counter_risk/cli/__init__.py @@ -197,6 +197,7 @@ def _run_with_discovery(args: argparse.Namespace) -> int: if hasattr(config, input_name): overrides[input_name] = path updated_config = config.model_copy(update=overrides) + formatting_profile = _effective_formatting_profile(args, runner_settings) config_dir = args.config.resolve().parent output_dir: Path | None = getattr(args, "output_dir", None) @@ -204,12 +205,12 @@ def _run_with_discovery(args: argparse.Namespace) -> int: updated_config, config_dir=config_dir, output_dir=output_dir, + formatting_profile=formatting_profile, ) - formatting_profile = _effective_formatting_profile(args, runner_settings) if formatting_profile: print( - "Note: formatting profile support will be applied in output formatters; " - f"recorded selection: {formatting_profile}" + "Note: formatting profile applied to supported output formatters; " + f"selected profile: {formatting_profile}" ) print(f"\nCounter Risk discovery run completed: {run_dir}") @@ -241,16 +242,17 @@ def _run_workflow_mode(args: argparse.Namespace) -> int: if export_pdf is not None: overrides["export_pdf"] = bool(export_pdf) runtime_config = config.model_copy(update=overrides) if overrides else config + formatting_profile = _effective_formatting_profile(args, runner_settings) run_dir = run_pipeline_with_config( runtime_config, config_dir=args.config.resolve().parent, output_dir=getattr(args, "output_dir", None), + formatting_profile=formatting_profile, ) - formatting_profile = _effective_formatting_profile(args, runner_settings) if formatting_profile: print( - "Note: formatting profile support will be applied in output formatters; " - f"recorded selection: {formatting_profile}" + "Note: formatting profile applied to supported output formatters; " + f"selected profile: {formatting_profile}" ) print(f"Counter Risk run completed: {run_dir}") return 0 diff --git a/src/counter_risk/formatting.py b/src/counter_risk/formatting.py new file mode 100644 index 00000000..eb3c7597 --- /dev/null +++ b/src/counter_risk/formatting.py @@ -0,0 +1,59 @@ +"""Formatting profile helpers for operator-facing numeric output controls.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final + +DEFAULT_FORMATTING_PROFILE: Final[str] = "default" + + +@dataclass(frozen=True) +class FormattingPolicy: + """Resolved numeric formatting behavior for one runtime profile.""" + + profile: str + notional_number_format: str | None + counterparties_number_format: str | None + + +_FORMATTING_POLICIES: Final[dict[str, FormattingPolicy]] = { + "default": FormattingPolicy( + profile="default", + notional_number_format=None, + counterparties_number_format=None, + ), + "currency": FormattingPolicy( + profile="currency", + notional_number_format="$#,##0.00;[Red]-$#,##0.00", + counterparties_number_format="0", + ), + "accounting": FormattingPolicy( + profile="accounting", + notional_number_format='_($* #,##0.00_);_($* (#,##0.00);_($* "-"??_);_(@_)', + counterparties_number_format="0", + ), + "plain": FormattingPolicy( + profile="plain", + notional_number_format="#,##0.00", + counterparties_number_format="0", + ), +} + + +def normalize_formatting_profile(profile: str | None) -> str: + """Return a normalized profile key with safe fallback to ``default``.""" + + if profile is None: + return DEFAULT_FORMATTING_PROFILE + normalized = profile.strip().lower() + if not normalized: + return DEFAULT_FORMATTING_PROFILE + return normalized if normalized in _FORMATTING_POLICIES else DEFAULT_FORMATTING_PROFILE + + +def resolve_formatting_policy(profile: str | None) -> FormattingPolicy: + """Resolve one formatting policy from a runtime profile selector.""" + + normalized = normalize_formatting_profile(profile) + return _FORMATTING_POLICIES[normalized] diff --git a/src/counter_risk/outputs/base.py b/src/counter_risk/outputs/base.py index 04bfb013..5de11560 100644 --- a/src/counter_risk/outputs/base.py +++ b/src/counter_risk/outputs/base.py @@ -18,6 +18,7 @@ class OutputContext: run_dir: Path as_of_date: date run_date: date + formatting_profile: str | None = None warnings: tuple[str, ...] = field(default_factory=tuple) diff --git a/src/counter_risk/outputs/historical_workbook.py b/src/counter_risk/outputs/historical_workbook.py index 521a113b..eab20745 100644 --- a/src/counter_risk/outputs/historical_workbook.py +++ b/src/counter_risk/outputs/historical_workbook.py @@ -53,6 +53,7 @@ def generate(self, *, context: OutputContext) -> tuple[Path, ...]: variant=variant, as_of_date=context.as_of_date, totals_records=totals_records, + formatting_profile=context.formatting_profile, warnings=self.warnings, ) output_paths.append(target_hist) diff --git a/src/counter_risk/pipeline/run.py b/src/counter_risk/pipeline/run.py index ef467f5b..28258fad 100644 --- a/src/counter_risk/pipeline/run.py +++ b/src/counter_risk/pipeline/run.py @@ -35,6 +35,7 @@ ) from counter_risk.config import WorkflowConfig, load_config from counter_risk.dates import derive_as_of_date, derive_run_date +from counter_risk.formatting import normalize_formatting_profile, resolve_formatting_policy from counter_risk.limits_config import load_limits_config from counter_risk.normalize import ( canonicalize_name, @@ -550,6 +551,7 @@ def run_pipeline_with_config( *, config_dir: Path, output_dir: Path | None = None, + formatting_profile: str | None = None, ) -> Path: """Run the full pipeline from an in-memory config object. @@ -576,7 +578,11 @@ def run_pipeline_with_config( original_working_directory = Path.cwd() try: os.chdir(config_dir) - return run_pipeline(temp_config_path, output_dir=output_dir) + return run_pipeline( + temp_config_path, + output_dir=output_dir, + formatting_profile=formatting_profile, + ) finally: with contextlib.suppress(OSError): os.chdir(original_working_directory) @@ -584,7 +590,12 @@ def run_pipeline_with_config( temp_config_path.unlink() -def run_pipeline(config_path: str | Path, *, output_dir: Path | None = None) -> Path: +def run_pipeline( + config_path: str | Path, + *, + output_dir: Path | None = None, + formatting_profile: str | None = None, +) -> Path: """Run the Counter Risk pipeline and return the output run directory.""" LOGGER.info("pipeline_start config_path=%s", config_path) @@ -644,6 +655,7 @@ def run_pipeline(config_path: str | Path, *, output_dir: Path | None = None) -> raise RuntimeError("Pipeline failed during run directory setup stage") from exc warnings: list[str] = [] + resolved_formatting_profile = normalize_formatting_profile(formatting_profile) _append_missing_inputs_warnings(missing_inputs=missing_inputs, warnings=warnings) runtime_config = config try: @@ -742,6 +754,7 @@ def run_pipeline(config_path: str | Path, *, output_dir: Path | None = None) -> config=runtime_config, parsed_by_variant=parsed_by_variant, as_of_date=as_of_date, + formatting_profile=resolved_formatting_profile, warnings=warnings, ) except Exception as exc: @@ -3035,6 +3048,7 @@ def _update_historical_outputs( config: WorkflowConfig, parsed_by_variant: dict[str, dict[str, Any]], as_of_date: date, + formatting_profile: str | None = None, warnings: list[str], ) -> list[Path]: LOGGER.info("historical_update_start run_dir=%s as_of_date=%s", run_dir, as_of_date.isoformat()) @@ -3052,6 +3066,7 @@ def _update_historical_outputs( run_dir=run_dir, as_of_date=as_of_date, run_date=config.run_date or as_of_date, + formatting_profile=formatting_profile, warnings=tuple(warnings), ) output_paths: list[Path] = [] @@ -3184,6 +3199,7 @@ def _merge_historical_workbook( variant: str, as_of_date: date, totals_records: list[dict[str, Any]], + formatting_profile: str | None = None, warnings: list[str], ) -> None: try: @@ -3217,8 +3233,15 @@ def _merge_historical_workbook( ) worksheet.cell(row=append_row, column=1).value = as_of_date - worksheet.cell(row=append_row, column=2).value = total_notional - worksheet.cell(row=append_row, column=3).value = counterparties + notional_cell = worksheet.cell(row=append_row, column=2) + counterparties_cell = worksheet.cell(row=append_row, column=3) + notional_cell.value = total_notional + counterparties_cell.value = counterparties + formatting_policy = resolve_formatting_policy(formatting_profile) + if formatting_policy.notional_number_format is not None: + notional_cell.number_format = formatting_policy.notional_number_format + if formatting_policy.counterparties_number_format is not None: + counterparties_cell.number_format = formatting_policy.counterparties_number_format workbook.save(workbook_path) LOGGER.info( "historical_update_variant_complete variant=%s row=%s notional=%s counterparties=%s", diff --git a/tests/outputs/test_historical_workbook.py b/tests/outputs/test_historical_workbook.py index 5cfec810..59e7d71f 100644 --- a/tests/outputs/test_historical_workbook.py +++ b/tests/outputs/test_historical_workbook.py @@ -153,9 +153,10 @@ def _fake_merge( variant: str, as_of_date: date, totals_records: list[dict[str, object]], + formatting_profile: str | None = None, warnings: list[str], ) -> None: - del warnings + del warnings, formatting_profile notional = cast(float, totals_records[0]["Notional"]) merged.append((workbook_path, variant, float(notional), as_of_date.month)) diff --git a/tests/pipeline/test_run_pipeline.py b/tests/pipeline/test_run_pipeline.py index 41cb0627..1bb01ba4 100644 --- a/tests/pipeline/test_run_pipeline.py +++ b/tests/pipeline/test_run_pipeline.py @@ -2994,6 +2994,68 @@ def test_merge_historical_workbook_fails_fast_when_required_headers_missing( assert broken.cell(row=3, column=1).value is None +def test_merge_historical_workbook_applies_currency_formatting_profile( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workbook_path = tmp_path / "hist.xlsx" + workbook_path.write_bytes(b"fixture") + + target = _FakeWorksheet("Total") + target.set_value(1, 1, "Date") + target.set_value(1, 2, "Series A") + target.set_value(1, 3, "Series B") + target.set_value(2, 1, "2025-12-31") + + workbook = _FakeWorkbook({"Total": target}) + monkeypatch.setitem( + sys.modules, "openpyxl", types.SimpleNamespace(load_workbook=lambda filename: workbook) + ) + + run_module._merge_historical_workbook( + workbook_path=workbook_path, + variant="all_programs", + as_of_date=date(2026, 2, 13), + totals_records=[{"Notional": 10.0, "counterparty": "A"}], + formatting_profile="currency", + warnings=[], + ) + + assert target.cell(row=3, column=2).number_format == "$#,##0.00;[Red]-$#,##0.00" + assert target.cell(row=3, column=3).number_format == "0" + + +def test_merge_historical_workbook_applies_accounting_formatting_profile( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + workbook_path = tmp_path / "hist.xlsx" + workbook_path.write_bytes(b"fixture") + + target = _FakeWorksheet("Total") + target.set_value(1, 1, "Date") + target.set_value(1, 2, "Series A") + target.set_value(1, 3, "Series B") + target.set_value(2, 1, "2025-12-31") + + workbook = _FakeWorkbook({"Total": target}) + monkeypatch.setitem( + sys.modules, "openpyxl", types.SimpleNamespace(load_workbook=lambda filename: workbook) + ) + + run_module._merge_historical_workbook( + workbook_path=workbook_path, + variant="all_programs", + as_of_date=date(2026, 2, 13), + totals_records=[{"Notional": 10.0, "counterparty": "A"}], + formatting_profile="accounting", + warnings=[], + ) + + notional_format = target.cell(row=3, column=2).number_format + assert isinstance(notional_format, str) + assert "#,##0.00" in notional_format + assert target.cell(row=3, column=3).number_format == "0" + + # --------------------------------------------------------------------------- # Static distribution fallback tests # --------------------------------------------------------------------------- diff --git a/tests/test_counter_risk_cli.py b/tests/test_counter_risk_cli.py index 8161ee33..569ccc0d 100644 --- a/tests/test_counter_risk_cli.py +++ b/tests/test_counter_risk_cli.py @@ -37,8 +37,10 @@ def test_main_run_command_returns_zero( run_dir = tmp_path / "run-output" - def fake_run_pipeline_with_config(config, *, config_dir, output_dir=None): - _ = (config, config_dir, output_dir) + def fake_run_pipeline_with_config( + config, *, config_dir, output_dir=None, formatting_profile=None + ): + _ = (config, config_dir, output_dir, formatting_profile) run_dir.mkdir(parents=True, exist_ok=True) return run_dir @@ -377,8 +379,10 @@ def test_main_run_discover_mode_auto_selects_and_runs( output_dir = tmp_path / "run-output" run_dir = output_dir.resolve() - def fake_run_pipeline_with_config(config, *, config_dir, output_dir=None): - _ = config_dir + def fake_run_pipeline_with_config( + config, *, config_dir, output_dir=None, formatting_profile=None + ): + _ = (config_dir, formatting_profile) output = run_dir if output_dir is None else Path(output_dir).resolve() output.mkdir(parents=True, exist_ok=True) manifest = { @@ -497,12 +501,15 @@ def test_main_run_applies_runner_settings_defaults( captured_config: dict[str, object] = {} - def fake_run_pipeline_with_config(config, *, config_dir, output_dir=None): + def fake_run_pipeline_with_config( + config, *, config_dir, output_dir=None, formatting_profile=None + ): captured_config["fail_policy"] = config.reconciliation.fail_policy captured_config["output_root"] = config.output_root captured_config["discovery_roots"] = dict(config.input_discovery.directory_roots) captured_config["config_dir"] = config_dir captured_config["output_dir"] = output_dir + captured_config["formatting_profile"] = formatting_profile run_dir = tmp_path / "run-output" run_dir.mkdir(parents=True, exist_ok=True) return run_dir @@ -523,7 +530,7 @@ def fake_run_pipeline_with_config(config, *, config_dir, output_dir=None): captured = capsys.readouterr() assert result == 0 - assert "recorded selection: accounting" in captured.out + assert "selected profile: accounting" in captured.out assert captured_config["fail_policy"] == "strict" assert captured_config["output_root"] == Path(str(tmp_path / "shared-runs")) assert captured_config["discovery_roots"] == { @@ -532,6 +539,7 @@ def fake_run_pipeline_with_config(config, *, config_dir, output_dir=None): "template_inputs": Path(str(tmp_path / "shared-inputs")), } assert captured_config["output_dir"] is None + assert captured_config["formatting_profile"] == "accounting" def test_main_run_uses_discovery_mode_from_settings( diff --git a/tests/test_formatting.py b/tests/test_formatting.py new file mode 100644 index 00000000..8ffd269a --- /dev/null +++ b/tests/test_formatting.py @@ -0,0 +1,37 @@ +"""Unit tests for operator formatting profile resolution.""" + +from __future__ import annotations + +from counter_risk.formatting import ( + DEFAULT_FORMATTING_PROFILE, + normalize_formatting_profile, + resolve_formatting_policy, +) + + +def test_normalize_formatting_profile_defaults_unknown_and_empty_values() -> None: + assert normalize_formatting_profile(None) == DEFAULT_FORMATTING_PROFILE + assert normalize_formatting_profile("") == DEFAULT_FORMATTING_PROFILE + assert normalize_formatting_profile(" ") == DEFAULT_FORMATTING_PROFILE + assert normalize_formatting_profile("not-a-profile") == DEFAULT_FORMATTING_PROFILE + + +def test_normalize_formatting_profile_accepts_known_profiles() -> None: + assert normalize_formatting_profile("currency") == "currency" + assert normalize_formatting_profile("ACCOUNTING") == "accounting" + assert normalize_formatting_profile(" plain ") == "plain" + + +def test_resolve_formatting_policy_returns_expected_excel_formats() -> None: + currency = resolve_formatting_policy("currency") + assert currency.notional_number_format == "$#,##0.00;[Red]-$#,##0.00" + assert currency.counterparties_number_format == "0" + + accounting = resolve_formatting_policy("accounting") + assert accounting.notional_number_format is not None + assert "#,##0.00" in accounting.notional_number_format + assert accounting.counterparties_number_format == "0" + + default = resolve_formatting_policy("default") + assert default.notional_number_format is None + assert default.counterparties_number_format is None diff --git a/tests/test_historical_workbook_validation.py b/tests/test_historical_workbook_validation.py index 8d7c3523..4286a639 100644 --- a/tests/test_historical_workbook_validation.py +++ b/tests/test_historical_workbook_validation.py @@ -451,8 +451,10 @@ def _fake_merge( variant: str, as_of_date: date, totals_records: list[dict[str, Any]], + formatting_profile: str | None = None, warnings: list[str], ) -> None: + _ = formatting_profile assert as_of_date == date(2026, 2, 13) merge_calls.append((workbook_path, variant, totals_records, warnings)) diff --git a/tests/test_pipeline_run_dir.py b/tests/test_pipeline_run_dir.py index 1ffdae3e..66d704f2 100644 --- a/tests/test_pipeline_run_dir.py +++ b/tests/test_pipeline_run_dir.py @@ -67,8 +67,9 @@ def _fake_historical_update( parsed_by_variant: dict[str, dict[str, Any]], as_of_date: Any, warnings: list[str], + formatting_profile: str, ) -> list[Path]: - _ = (config, parsed_by_variant, as_of_date, warnings) + _ = (config, parsed_by_variant, as_of_date, warnings, formatting_profile) output = run_dir / "historical-output.xlsx" output.write_bytes(b"historical") return [output] @@ -139,8 +140,9 @@ def _fake_historical_update( parsed_by_variant: dict[str, dict[str, Any]], as_of_date: Any, warnings: list[str], + formatting_profile: str, ) -> list[Path]: - _ = (config, parsed_by_variant, as_of_date, warnings) + _ = (config, parsed_by_variant, as_of_date, warnings, formatting_profile) output = run_dir / "historical-output.xlsx" output.write_bytes(b"historical") return [output] @@ -199,7 +201,7 @@ def test_pipeline_run_directory_includes_run_date_when_configured( monkeypatch.setattr("counter_risk.pipeline.run._compute_metrics", lambda _: ({}, {})) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", - lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], + lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings, formatting_profile: [], ) monkeypatch.setattr( "counter_risk.pipeline.run._write_outputs", diff --git a/tests/test_ppt_status_reporting.py b/tests/test_ppt_status_reporting.py index e334d2c9..8d977f14 100644 --- a/tests/test_ppt_status_reporting.py +++ b/tests/test_ppt_status_reporting.py @@ -66,8 +66,9 @@ def _fake_historical_update( parsed_by_variant: dict[str, dict[str, Any]], as_of_date: Any, warnings: list[str], + formatting_profile: str, ) -> list[Path]: - _ = (config, parsed_by_variant, as_of_date, warnings) + _ = (config, parsed_by_variant, as_of_date, warnings, formatting_profile) output = run_dir / "historical-output.xlsx" output.write_bytes(b"historical") return [output]