perfaud 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. perfaud/__init__.py +9 -0
  2. perfaud/aliases.py +48 -0
  3. perfaud/atomic_directory.py +221 -0
  4. perfaud/axys_apx/__init__.py +1 -0
  5. perfaud/axys_apx/security_identity.py +394 -0
  6. perfaud/axys_apx/transaction_safety.py +13 -0
  7. perfaud/base_currency.py +204 -0
  8. perfaud/bundle.py +1314 -0
  9. perfaud/cli/__init__.py +5 -0
  10. perfaud/cli/__main__.py +5 -0
  11. perfaud/cli/main.py +45 -0
  12. perfaud/cli/run.py +36 -0
  13. perfaud/cli/setup.py +119 -0
  14. perfaud/comparison/__init__.py +55 -0
  15. perfaud/comparison/_transaction_diagnostics.py +252 -0
  16. perfaud/comparison/compare.py +2397 -0
  17. perfaud/comparison/explain.py +3368 -0
  18. perfaud/comparison/findings.py +447 -0
  19. perfaud/comparison/methods.py +103 -0
  20. perfaud/comparison/modified_dietz.py +131 -0
  21. perfaud/comparison/policies.py +1221 -0
  22. perfaud/comparison/return_reconstruction.py +1342 -0
  23. perfaud/comparison/rules.py +168 -0
  24. perfaud/comparison/vocabulary.py +14 -0
  25. perfaud/config.py +521 -0
  26. perfaud/conservation.py +750 -0
  27. perfaud/currency_basis.py +147 -0
  28. perfaud/data_issues/__init__.py +23 -0
  29. perfaud/data_issues/checks.py +2293 -0
  30. perfaud/data_issues/config.py +600 -0
  31. perfaud/data_issues/vocabulary.py +222 -0
  32. perfaud/errors.py +30 -0
  33. perfaud/executive_summary.py +363 -0
  34. perfaud/extract_contract.py +470 -0
  35. perfaud/field_roles.py +128 -0
  36. perfaud/financial_integrity.py +453 -0
  37. perfaud/holdings.py +214 -0
  38. perfaud/lineage.py +524 -0
  39. perfaud/output_integrity.py +656 -0
  40. perfaud/output_policy.py +96 -0
  41. perfaud/paths.py +55 -0
  42. perfaud/period_linking.py +313 -0
  43. perfaud/portfolio_performance.py +114 -0
  44. perfaud/py.typed +0 -0
  45. perfaud/rendering.py +702 -0
  46. perfaud/report.py +1547 -0
  47. perfaud/review.py +275 -0
  48. perfaud/runner.py +448 -0
  49. perfaud/safety_invariants.py +370 -0
  50. perfaud/schema.py +304 -0
  51. perfaud/security_master.py +118 -0
  52. perfaud/security_performance.py +134 -0
  53. perfaud/source_data_contract.py +172 -0
  54. perfaud/source_loader.py +777 -0
  55. perfaud/specification.py +856 -0
  56. perfaud/splits.py +87 -0
  57. perfaud/templates/axys_apx/README.md +107 -0
  58. perfaud/templates/axys_apx/demo_extract_availability.yaml +1316 -0
  59. perfaud/templates/axys_apx/input/snapshot_a/holdings.csv +467 -0
  60. perfaud/templates/axys_apx/input/snapshot_a/portperf.csv +31 -0
  61. perfaud/templates/axys_apx/input/snapshot_a/secmast.csv +23 -0
  62. perfaud/templates/axys_apx/input/snapshot_a/secperf.csv +467 -0
  63. perfaud/templates/axys_apx/input/snapshot_a/splits.csv +1 -0
  64. perfaud/templates/axys_apx/input/snapshot_a/transactions.csv +37 -0
  65. perfaud/templates/axys_apx/input/snapshot_b/holdings.csv +467 -0
  66. perfaud/templates/axys_apx/input/snapshot_b/portperf.csv +31 -0
  67. perfaud/templates/axys_apx/input/snapshot_b/secmast.csv +23 -0
  68. perfaud/templates/axys_apx/input/snapshot_b/secperf.csv +467 -0
  69. perfaud/templates/axys_apx/input/snapshot_b/splits.csv +2 -0
  70. perfaud/templates/axys_apx/input/snapshot_b/transactions.csv +50 -0
  71. perfaud/templates/axys_apx/perfaud.yaml +732 -0
  72. perfaud/transaction_codes.py +21 -0
  73. perfaud/transaction_summary.py +166 -0
  74. perfaud/transactions.py +907 -0
  75. perfaud/workbook/__init__.py +1 -0
  76. perfaud/workbook/formula_rows.py +496 -0
  77. perfaud/workbook/guidance.py +962 -0
  78. perfaud/workbook/layout.py +488 -0
  79. perfaud/workbook/reconstruction.py +173 -0
  80. perfaud/workbook/rows.py +257 -0
  81. perfaud/workbook/source_allocation.py +474 -0
  82. perfaud/workbook/tables.py +2678 -0
  83. perfaud/workbook/writer.py +1046 -0
  84. perfaud/workspace.py +175 -0
  85. perfaud-0.1.0.dist-info/METADATA +94 -0
  86. perfaud-0.1.0.dist-info/RECORD +90 -0
  87. perfaud-0.1.0.dist-info/WHEEL +5 -0
  88. perfaud-0.1.0.dist-info/entry_points.txt +2 -0
  89. perfaud-0.1.0.dist-info/licenses/LICENSE +138 -0
  90. perfaud-0.1.0.dist-info/top_level.txt +1 -0
perfaud/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Audit changes in reported portfolio performance."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from perfaud.workspace import run
6
+
7
+ __version__ = version("perfaud")
8
+
9
+ __all__ = ["run", "__version__"]
perfaud/aliases.py ADDED
@@ -0,0 +1,48 @@
1
+ """Define exact-default source columns for normalized Audit datasets.
2
+
3
+ Vendor and site-specific source headings belong in explicit schema YAML. When
4
+ no schema mapping is configured for a normalized field, Audit accepts only the
5
+ exact normalized field name, including case.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # Project imports
11
+ from perfaud import schema as pc_cols
12
+ from perfaud.source_loader import ColumnAliases
13
+
14
+
15
+ def _exact_aliases(columns: tuple[str, ...]) -> ColumnAliases:
16
+ """Return one exact source-name candidate per normalized column."""
17
+ return {column: (column,) for column in columns}
18
+
19
+
20
+ PORTFOLIO_PERFORMANCE_REQUIRED_ALIASES = _exact_aliases(
21
+ pc_cols.PORTFOLIO_PERFORMANCE_REQUIRED_COLUMNS
22
+ )
23
+ PORTFOLIO_PERFORMANCE_OPTIONAL_ALIASES = _exact_aliases(
24
+ pc_cols.PORTFOLIO_PERFORMANCE_OPTIONAL_COLUMNS
25
+ )
26
+
27
+ SECURITY_PERFORMANCE_REQUIRED_ALIASES = _exact_aliases(
28
+ pc_cols.SECURITY_PERFORMANCE_REQUIRED_COLUMNS
29
+ )
30
+ SECURITY_PERFORMANCE_OPTIONAL_ALIASES = _exact_aliases(
31
+ pc_cols.SECURITY_PERFORMANCE_OPTIONAL_COLUMNS
32
+ )
33
+
34
+ SPLITS_REQUIRED_ALIASES = _exact_aliases(pc_cols.SPLITS_REQUIRED_COLUMNS)
35
+ SPLITS_OPTIONAL_ALIASES = _exact_aliases(pc_cols.SPLITS_OPTIONAL_COLUMNS)
36
+
37
+ TRANSACTIONS_REQUIRED_ALIASES = _exact_aliases(pc_cols.TRANSACTIONS_REQUIRED_COLUMNS)
38
+ TRANSACTIONS_OPTIONAL_ALIASES = _exact_aliases(pc_cols.TRANSACTIONS_OPTIONAL_COLUMNS)
39
+
40
+ HOLDINGS_REQUIRED_ALIASES = _exact_aliases(pc_cols.HOLDINGS_REQUIRED_COLUMNS)
41
+ HOLDINGS_OPTIONAL_ALIASES = _exact_aliases(pc_cols.HOLDINGS_OPTIONAL_COLUMNS)
42
+
43
+ SECURITY_MASTER_REQUIRED_ALIASES = _exact_aliases(
44
+ pc_cols.SECURITY_MASTER_REQUIRED_COLUMNS
45
+ )
46
+ SECURITY_MASTER_OPTIONAL_ALIASES = _exact_aliases(
47
+ pc_cols.SECURITY_MASTER_OPTIONAL_COLUMNS
48
+ )
@@ -0,0 +1,221 @@
1
+ """Stage and atomically promote complete Audit output directories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Python imports
6
+ from collections.abc import Iterator
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+ import shutil
10
+ import tempfile
11
+
12
+ # Project imports
13
+ from perfaud.errors import PerfaudError
14
+ import perfaud.paths as util
15
+
16
+
17
+ @contextmanager
18
+ def staged_directory(destination: util.PathLike) -> Iterator[Path]:
19
+ """Yield a sibling staging directory and promote it after successful use.
20
+
21
+ Args:
22
+ destination: Final directory that should receive the staged contents.
23
+
24
+ Yields:
25
+ Empty sibling directory for building and validating complete output.
26
+
27
+ Raises:
28
+ PerfaudError: If the destination exists but is not a directory, or if a
29
+ failed promotion cannot restore the previous directory.
30
+ OSError: If staging or promotion fails.
31
+
32
+ Notes:
33
+ The previous destination remains unchanged if work inside the context
34
+ fails. Promotion briefly renames an existing destination to a sibling
35
+ backup, installs the staged directory, and then removes the backup.
36
+ """
37
+ destination_path = Path(destination)
38
+ if destination_path.exists() and not destination_path.is_dir():
39
+ raise PerfaudError(
40
+ f"{destination_path} exists but is not a directory.",
41
+ )
42
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
43
+ staging_path = Path(
44
+ tempfile.mkdtemp(
45
+ prefix=f".{destination_path.name}.staging-",
46
+ dir=destination_path.parent,
47
+ )
48
+ )
49
+ try:
50
+ yield staging_path
51
+ except BaseException:
52
+ shutil.rmtree(staging_path, ignore_errors=True)
53
+ raise
54
+
55
+ try:
56
+ _promote_staged_directory(staging_path, destination_path)
57
+ except BaseException:
58
+ shutil.rmtree(staging_path, ignore_errors=True)
59
+ raise
60
+
61
+
62
+ @contextmanager
63
+ def staged_children(
64
+ destination_root: util.PathLike,
65
+ child_names: tuple[str, ...],
66
+ ) -> Iterator[Path]:
67
+ """Yield a staging root and atomically replace selected child directories.
68
+
69
+ Args:
70
+ destination_root: Parent containing the managed output directories.
71
+ child_names: Exact child directory names owned by the current run.
72
+
73
+ Yields:
74
+ Empty sibling staging root. A missing managed child at successful
75
+ promotion removes any older destination child with the same name.
76
+
77
+ Raises:
78
+ PerfaudError: If a managed destination is not a directory or recovery fails.
79
+ OSError: If staging or promotion fails.
80
+
81
+ Notes:
82
+ Files and directories below ``destination_root`` whose names are not in
83
+ ``child_names`` remain untouched.
84
+ """
85
+ destination_path = Path(destination_root)
86
+ if destination_path.exists() and not destination_path.is_dir():
87
+ raise PerfaudError(
88
+ f"{destination_path} exists but is not a directory.",
89
+ )
90
+ if not child_names or len(set(child_names)) != len(child_names):
91
+ raise ValueError("child_names must contain unique managed directory names.")
92
+ if any(Path(name).name != name or name in {"", ".", ".."} for name in child_names):
93
+ raise ValueError("child_names must contain plain directory names.")
94
+
95
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
96
+ staging_root = Path(
97
+ tempfile.mkdtemp(
98
+ prefix=f".{destination_path.name}.run-staging-",
99
+ dir=destination_path.parent,
100
+ )
101
+ )
102
+ try:
103
+ yield staging_root
104
+ except BaseException:
105
+ shutil.rmtree(staging_root, ignore_errors=True)
106
+ raise
107
+
108
+ try:
109
+ _promote_staged_children(
110
+ staging_root,
111
+ destination_path,
112
+ child_names,
113
+ )
114
+ except BaseException:
115
+ shutil.rmtree(staging_root, ignore_errors=True)
116
+ raise
117
+
118
+
119
+ def remap_staged_path(
120
+ path: Path,
121
+ *,
122
+ staging_root: Path,
123
+ destination_root: Path,
124
+ ) -> Path:
125
+ """Return the final path corresponding to a path inside a staging root.
126
+
127
+ Args:
128
+ path: Artifact path returned while writing the staging directory.
129
+ staging_root: Root directory used for the staged build.
130
+ destination_root: Final promoted directory.
131
+
132
+ Returns:
133
+ Artifact path below ``destination_root`` with the same relative path.
134
+ """
135
+ return destination_root / path.relative_to(staging_root)
136
+
137
+
138
+ def _promote_staged_directory(staging_path: Path, destination_path: Path) -> None:
139
+ """Replace one destination directory while preserving recovery on failure."""
140
+ if not destination_path.exists():
141
+ staging_path.replace(destination_path)
142
+ return
143
+
144
+ backup_path = Path(
145
+ tempfile.mkdtemp(
146
+ prefix=f".{destination_path.name}.backup-",
147
+ dir=destination_path.parent,
148
+ )
149
+ )
150
+ backup_path.rmdir()
151
+ destination_path.replace(backup_path)
152
+ try:
153
+ staging_path.replace(destination_path)
154
+ except BaseException as promotion_error:
155
+ try:
156
+ backup_path.replace(destination_path)
157
+ except OSError as restoration_error:
158
+ raise PerfaudError(
159
+ "Audit output promotion failed and the previous output could "
160
+ f"not be restored from {backup_path}: {restoration_error}",
161
+ ) from promotion_error
162
+ raise
163
+ shutil.rmtree(backup_path, ignore_errors=True)
164
+
165
+
166
+ def _promote_staged_children(
167
+ staging_root: Path,
168
+ destination_root: Path,
169
+ child_names: tuple[str, ...],
170
+ ) -> None:
171
+ """Promote a complete set of managed children with rollback."""
172
+ for child_name in child_names:
173
+ staged_child = staging_root / child_name
174
+ destination_child = destination_root / child_name
175
+ if staged_child.exists() and not staged_child.is_dir():
176
+ raise PerfaudError(
177
+ f"Staged Audit output {staged_child} is not a directory.",
178
+ )
179
+ if destination_child.exists() and not destination_child.is_dir():
180
+ raise PerfaudError(
181
+ f"{destination_child} exists but is not a directory.",
182
+ )
183
+
184
+ destination_existed = destination_root.exists()
185
+ destination_root.mkdir(parents=True, exist_ok=True)
186
+ backup_root = Path(
187
+ tempfile.mkdtemp(
188
+ prefix=f".{destination_root.name}.run-backup-",
189
+ dir=destination_root.parent,
190
+ )
191
+ )
192
+ promoted_names: list[str] = []
193
+ try:
194
+ for child_name in child_names:
195
+ destination_child = destination_root / child_name
196
+ if destination_child.exists():
197
+ destination_child.replace(backup_root / child_name)
198
+ for child_name in child_names:
199
+ staged_child = staging_root / child_name
200
+ if staged_child.exists():
201
+ staged_child.replace(destination_root / child_name)
202
+ promoted_names.append(child_name)
203
+ except BaseException as promotion_error:
204
+ for child_name in promoted_names:
205
+ shutil.rmtree(destination_root / child_name, ignore_errors=True)
206
+ try:
207
+ for child_name in child_names:
208
+ backup_child = backup_root / child_name
209
+ if backup_child.exists():
210
+ backup_child.replace(destination_root / child_name)
211
+ if not destination_existed and not any(destination_root.iterdir()):
212
+ destination_root.rmdir()
213
+ except OSError as restoration_error:
214
+ raise PerfaudError(
215
+ "Audit run promotion failed and previous report directories "
216
+ f"could not be restored from {backup_root}: {restoration_error}",
217
+ ) from promotion_error
218
+ raise
219
+
220
+ shutil.rmtree(backup_root, ignore_errors=True)
221
+ shutil.rmtree(staging_root, ignore_errors=True)
@@ -0,0 +1 @@
1
+ """Axys/APX-specific identity and transaction-safety contracts."""
@@ -0,0 +1,394 @@
1
+ """Construct stable perfaud security identifiers from Axys/APX source fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Python imports
6
+ from collections.abc import Callable, Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Final, cast
9
+
10
+ # Third-party imports
11
+ import polars as pl
12
+
13
+ # Project imports
14
+ from perfaud.errors import PerfaudError
15
+ from perfaud.config import source_file_columns
16
+ import perfaud.paths as util
17
+
18
+ _SECURITY_ID_KEY: Final = "security_id"
19
+ _COMPONENTS_KEY: Final = "components"
20
+ _SEPARATOR_KEY: Final = "separator"
21
+ _DATASETS_KEY: Final = "datasets"
22
+ _DEFAULT_SEPARATOR: Final = ""
23
+ _DEFAULT_COMPONENTS: Final = ("security_type", "security_symbol")
24
+ _CONFIGURATION_FIELDS: Final = {
25
+ _COMPONENTS_KEY,
26
+ _SEPARATOR_KEY,
27
+ _DATASETS_KEY,
28
+ }
29
+ _DATASET_FIELDS: Final = {
30
+ _COMPONENTS_KEY,
31
+ _SEPARATOR_KEY,
32
+ }
33
+ _SECURITY_DATASETS: Final = {
34
+ "holdings",
35
+ "security_performance",
36
+ "security_master",
37
+ "splits",
38
+ "transactions",
39
+ }
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class SecurityIdConstruction:
44
+ """Describe normalized and source fields used to construct a security key.
45
+
46
+ Attributes:
47
+ components: Normalized mapping keys, in concatenation order.
48
+ source_columns: Exact-case source CSV columns resolved for the dataset.
49
+ separator: Optional text inserted between adjacent component values.
50
+ """
51
+
52
+ components: tuple[str, ...]
53
+ source_columns: tuple[str, ...]
54
+ separator: str
55
+
56
+ @property
57
+ def schema_overrides(self) -> dict[str, type[pl.DataType]]:
58
+ """Return CSV schema overrides that preserve component text exactly."""
59
+ return {source_column: pl.String for source_column in self.source_columns}
60
+
61
+
62
+ def security_id_construction(
63
+ values: Mapping[str, object],
64
+ dataset_name: str,
65
+ error_message: Callable[[str], str],
66
+ *,
67
+ file_name: str | None = None,
68
+ ) -> SecurityIdConstruction | None:
69
+ """Return validated security-ID construction for one source dataset.
70
+
71
+ Args:
72
+ values: Parsed YAML root mapping.
73
+ dataset_name: Normalized source dataset name.
74
+ error_message: Callback that adds product-specific error context.
75
+ file_name: Optional ``files`` dataset-name override. Analytics uses
76
+ ``security_master`` for its security-master source.
77
+
78
+ Returns:
79
+ Construction settings for a security-bearing dataset. When
80
+ ``security_id`` is omitted, layouts that do not map ``security_id``
81
+ directly and do map both ``security_type`` and ``security_symbol`` use
82
+ the Axys/APX defaults. Other layouts return ``None``.
83
+
84
+ Raises:
85
+ PerfaudError: If the configuration shape, component names, or separator is
86
+ invalid.
87
+ """
88
+ if dataset_name not in _SECURITY_DATASETS:
89
+ return None
90
+ configured_file_name = file_name or dataset_name
91
+ raw_configuration = values.get(_SECURITY_ID_KEY)
92
+ if raw_configuration is None:
93
+ columns = source_file_columns(
94
+ values,
95
+ configured_file_name,
96
+ error_message,
97
+ )
98
+ if _SECURITY_ID_KEY in columns:
99
+ return None
100
+ if not all(component in columns for component in _DEFAULT_COMPONENTS):
101
+ return None
102
+ return SecurityIdConstruction(
103
+ components=_DEFAULT_COMPONENTS,
104
+ source_columns=_source_columns(
105
+ values,
106
+ configured_file_name,
107
+ _DEFAULT_COMPONENTS,
108
+ error_message,
109
+ ),
110
+ separator=_DEFAULT_SEPARATOR,
111
+ )
112
+ configuration = _require_mapping(
113
+ raw_configuration,
114
+ _SECURITY_ID_KEY,
115
+ error_message,
116
+ )
117
+ _reject_unknown_fields(
118
+ configuration,
119
+ _CONFIGURATION_FIELDS,
120
+ _SECURITY_ID_KEY,
121
+ error_message,
122
+ )
123
+
124
+ dataset_configuration: Mapping[str, object] = {}
125
+ raw_datasets = configuration.get(_DATASETS_KEY, {})
126
+ datasets = _require_mapping(
127
+ raw_datasets,
128
+ f"{_SECURITY_ID_KEY}.{_DATASETS_KEY}",
129
+ error_message,
130
+ )
131
+ _reject_unknown_fields(
132
+ datasets,
133
+ _SECURITY_DATASETS,
134
+ f"{_SECURITY_ID_KEY}.{_DATASETS_KEY}",
135
+ error_message,
136
+ )
137
+ raw_dataset_configuration = datasets.get(dataset_name)
138
+ if raw_dataset_configuration is not None:
139
+ dataset_configuration = _require_mapping(
140
+ raw_dataset_configuration,
141
+ f"{_SECURITY_ID_KEY}.{_DATASETS_KEY}.{dataset_name}",
142
+ error_message,
143
+ )
144
+ _reject_unknown_fields(
145
+ dataset_configuration,
146
+ _DATASET_FIELDS,
147
+ f"{_SECURITY_ID_KEY}.{_DATASETS_KEY}.{dataset_name}",
148
+ error_message,
149
+ )
150
+
151
+ components_value = dataset_configuration.get(
152
+ _COMPONENTS_KEY,
153
+ configuration.get(_COMPONENTS_KEY),
154
+ )
155
+ components = _validate_components(
156
+ components_value,
157
+ dataset_name,
158
+ error_message,
159
+ )
160
+ separator_value = dataset_configuration.get(
161
+ _SEPARATOR_KEY,
162
+ configuration.get(_SEPARATOR_KEY, _DEFAULT_SEPARATOR),
163
+ )
164
+ separator = _validate_separator(
165
+ separator_value,
166
+ dataset_name,
167
+ error_message,
168
+ )
169
+ source_columns = _source_columns(
170
+ values,
171
+ configured_file_name,
172
+ components,
173
+ error_message,
174
+ )
175
+ return SecurityIdConstruction(
176
+ components=components,
177
+ source_columns=source_columns,
178
+ separator=separator,
179
+ )
180
+
181
+
182
+ def with_constructed_security_id(
183
+ frame: pl.DataFrame,
184
+ construction: SecurityIdConstruction,
185
+ *,
186
+ output_column: str,
187
+ dataset_name: str,
188
+ source_path: util.PathLike,
189
+ error_message: Callable[[str], str],
190
+ ) -> pl.DataFrame:
191
+ """Add a validated composite security identifier to a source frame.
192
+
193
+ Args:
194
+ frame: Raw source CSV frame.
195
+ construction: Ordered source columns and separator.
196
+ output_column: Temporary or normalized constructed-ID column name.
197
+ dataset_name: Normalized source dataset name for errors.
198
+ source_path: Source CSV path for errors.
199
+ error_message: Callback that adds product-specific error context.
200
+
201
+ Returns:
202
+ A new frame containing ``output_column``.
203
+
204
+ Raises:
205
+ PerfaudError: If a component column is missing, blank, padded with
206
+ whitespace, or produces an ambiguous composite identifier.
207
+
208
+ Notes:
209
+ Symbols may contain the configured separator. perfaud therefore checks
210
+ the observed component tuples for ambiguous concatenation instead of
211
+ rejecting legitimate Axys/APX symbols such as ``FUND_A``.
212
+ """
213
+ missing_columns = set(construction.source_columns) - set(frame.columns)
214
+ if missing_columns:
215
+ raise PerfaudError(
216
+ error_message(
217
+ f"Missing security_id component columns {sorted(missing_columns)} "
218
+ f"in {str(source_path)!r} for {dataset_name}. CSV columns "
219
+ f"available are: {sorted(frame.columns)}"
220
+ ),
221
+ )
222
+
223
+ string_expressions = {
224
+ component: pl.col(source_column).cast(pl.String, strict=False)
225
+ for component, source_column in zip(
226
+ construction.components,
227
+ construction.source_columns,
228
+ strict=True,
229
+ )
230
+ }
231
+ for component, source_column in zip(
232
+ construction.components,
233
+ construction.source_columns,
234
+ strict=True,
235
+ ):
236
+ string_expression = string_expressions[component]
237
+ stripped_expression = string_expression.str.strip_chars()
238
+ invalid_rows = frame.filter(
239
+ string_expression.is_null()
240
+ | stripped_expression.eq("")
241
+ | string_expression.ne(stripped_expression)
242
+ )
243
+ if not invalid_rows.is_empty():
244
+ value = invalid_rows.get_column(source_column)[0]
245
+ raise PerfaudError(
246
+ error_message(
247
+ f"security_id component {component!r}, mapped to source column "
248
+ f"{source_column!r}, contains a blank, null, or "
249
+ f"whitespace-padded value {value!r} in {str(source_path)!r} "
250
+ f"for {dataset_name}."
251
+ ),
252
+ )
253
+ result = frame.with_columns(
254
+ pl.concat_str(
255
+ [string_expressions[component] for component in construction.components],
256
+ separator=construction.separator,
257
+ ).alias(output_column)
258
+ )
259
+ collisions = (
260
+ result.select((*construction.source_columns, output_column))
261
+ .unique()
262
+ .group_by(output_column)
263
+ .len()
264
+ .filter(pl.col("len") > 1)
265
+ )
266
+ if not collisions.is_empty():
267
+ identifier = collisions.get_column(output_column)[0]
268
+ raise PerfaudError(
269
+ error_message(
270
+ f"Distinct security_id component tuples produce ambiguous "
271
+ f"identifier {identifier!r} in {str(source_path)!r} for "
272
+ f"{dataset_name}. Choose a different separator."
273
+ ),
274
+ )
275
+ return result
276
+
277
+
278
+ def _require_mapping(
279
+ value: object,
280
+ field_path: str,
281
+ error_message: Callable[[str], str],
282
+ ) -> Mapping[str, object]:
283
+ """Return ``value`` as a mapping or raise a configuration error."""
284
+ if not isinstance(value, Mapping):
285
+ raise PerfaudError(error_message(f"{field_path} must be a mapping."))
286
+ return cast(Mapping[str, object], value)
287
+
288
+
289
+ def _reject_unknown_fields(
290
+ values: Mapping[str, object],
291
+ allowed_fields: set[str],
292
+ field_path: str,
293
+ error_message: Callable[[str], str],
294
+ ) -> None:
295
+ """Raise when a security-ID configuration mapping has unknown fields."""
296
+ unknown_fields = set(values) - allowed_fields
297
+ if unknown_fields:
298
+ raise PerfaudError(
299
+ error_message(
300
+ f"Unknown fields for {field_path}: "
301
+ f"{sorted(map(str, unknown_fields))}"
302
+ ),
303
+ )
304
+
305
+
306
+ def _validate_components(
307
+ value: object,
308
+ dataset_name: str,
309
+ error_message: Callable[[str], str],
310
+ ) -> tuple[str, ...]:
311
+ """Return validated ordered normalized security-ID component names."""
312
+ if not isinstance(value, list) or len(value) < 2:
313
+ raise PerfaudError(
314
+ error_message(
315
+ f"security_id components for {dataset_name} must be a list of "
316
+ "at least two normalized field names."
317
+ ),
318
+ )
319
+ if any(not isinstance(component, str) or not component for component in value):
320
+ raise PerfaudError(
321
+ error_message(
322
+ f"security_id components for {dataset_name} must be nonempty strings."
323
+ ),
324
+ )
325
+ components = tuple(value)
326
+ if len(set(components)) != len(components):
327
+ raise PerfaudError(
328
+ error_message(
329
+ f"security_id components for {dataset_name} must be distinct: "
330
+ f"{list(components)}"
331
+ ),
332
+ )
333
+ invalid_components = [
334
+ component
335
+ for component in components
336
+ if not component.isidentifier() or component.lower() != component
337
+ ]
338
+ if invalid_components:
339
+ raise PerfaudError(
340
+ error_message(
341
+ f"security_id components for {dataset_name} must use normalized "
342
+ f"field names: {invalid_components}"
343
+ ),
344
+ )
345
+ return components
346
+
347
+
348
+ def _source_columns(
349
+ values: Mapping[str, object],
350
+ file_name: str,
351
+ components: tuple[str, ...],
352
+ error_message: Callable[[str], str],
353
+ ) -> tuple[str, ...]:
354
+ """Resolve normalized identity components through one file layout."""
355
+ section = source_file_columns(
356
+ values,
357
+ file_name,
358
+ error_message,
359
+ )
360
+ source_columns: list[str] = []
361
+ for component in components:
362
+ source_column = section.get(component, component)
363
+ if not isinstance(source_column, str) or not source_column:
364
+ raise PerfaudError(
365
+ error_message(
366
+ f"files.{file_name}.columns.{component} must be a nonempty "
367
+ "source column name."
368
+ ),
369
+ )
370
+ source_columns.append(source_column)
371
+ if len(set(source_columns)) != len(source_columns):
372
+ raise PerfaudError(
373
+ error_message(
374
+ f"security_id components for files.{file_name}.columns must map "
375
+ f"to distinct source columns: {source_columns}"
376
+ ),
377
+ )
378
+ return tuple(source_columns)
379
+
380
+
381
+ def _validate_separator(
382
+ value: object,
383
+ dataset_name: str,
384
+ error_message: Callable[[str], str],
385
+ ) -> str:
386
+ """Return a validated composite security-ID separator."""
387
+ if not isinstance(value, str) or "\n" in value or "\r" in value:
388
+ raise PerfaudError(
389
+ error_message(
390
+ f"security_id separator for {dataset_name} must be a single-line "
391
+ "string."
392
+ ),
393
+ )
394
+ return value
@@ -0,0 +1,13 @@
1
+ """Fail-closed Axys/APX transaction boundaries.
2
+
3
+ This module intentionally does not assign economic meaning. Local transaction
4
+ meaning belongs in Audit ``transaction_rules``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Final
10
+
11
+ AMBIGUOUS_FLOW_TRANSACTION_CODES: Final[frozenset[str]] = frozenset(
12
+ {"dp", "li", "lo", "ti", "wd"}
13
+ )