Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,22 @@ Optional runtime overrides:

- `--strict-policy warn|strict` (reconciliation fail policy override)
- `--export-pdf` / `--no-export-pdf`
- `--formatting-profile <name>` (reserved selector for formatter policy wiring)
- `--formatting-profile <name>` (applies supported numeric formatting policies to output writers)
- `--settings <path/to/runner-settings.json>` (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 <path>`
Expand Down
2 changes: 1 addition & 1 deletion docs/operator_ux_decision.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 8 additions & 6 deletions src/counter_risk/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,19 +197,20 @@ 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)
run_dir = run_pipeline_with_config(
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}")
Expand Down Expand Up @@ -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
Expand Down
59 changes: 59 additions & 0 deletions src/counter_risk/formatting.py
Original file line number Diff line number Diff line change
@@ -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]
1 change: 1 addition & 0 deletions src/counter_risk/outputs/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand Down
1 change: 1 addition & 0 deletions src/counter_risk/outputs/historical_workbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
31 changes: 27 additions & 4 deletions src/counter_risk/pipeline/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.

Expand All @@ -576,15 +578,24 @@ 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)
with contextlib.suppress(OSError):
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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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())
Expand All @@ -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] = []
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion tests/outputs/test_historical_workbook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
62 changes: 62 additions & 0 deletions tests/pipeline/test_run_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
20 changes: 14 additions & 6 deletions tests/test_counter_risk_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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
Expand All @@ -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"] == {
Expand All @@ -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(
Expand Down
Loading
Loading