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.
- perfaud/__init__.py +9 -0
- perfaud/aliases.py +48 -0
- perfaud/atomic_directory.py +221 -0
- perfaud/axys_apx/__init__.py +1 -0
- perfaud/axys_apx/security_identity.py +394 -0
- perfaud/axys_apx/transaction_safety.py +13 -0
- perfaud/base_currency.py +204 -0
- perfaud/bundle.py +1314 -0
- perfaud/cli/__init__.py +5 -0
- perfaud/cli/__main__.py +5 -0
- perfaud/cli/main.py +45 -0
- perfaud/cli/run.py +36 -0
- perfaud/cli/setup.py +119 -0
- perfaud/comparison/__init__.py +55 -0
- perfaud/comparison/_transaction_diagnostics.py +252 -0
- perfaud/comparison/compare.py +2397 -0
- perfaud/comparison/explain.py +3368 -0
- perfaud/comparison/findings.py +447 -0
- perfaud/comparison/methods.py +103 -0
- perfaud/comparison/modified_dietz.py +131 -0
- perfaud/comparison/policies.py +1221 -0
- perfaud/comparison/return_reconstruction.py +1342 -0
- perfaud/comparison/rules.py +168 -0
- perfaud/comparison/vocabulary.py +14 -0
- perfaud/config.py +521 -0
- perfaud/conservation.py +750 -0
- perfaud/currency_basis.py +147 -0
- perfaud/data_issues/__init__.py +23 -0
- perfaud/data_issues/checks.py +2293 -0
- perfaud/data_issues/config.py +600 -0
- perfaud/data_issues/vocabulary.py +222 -0
- perfaud/errors.py +30 -0
- perfaud/executive_summary.py +363 -0
- perfaud/extract_contract.py +470 -0
- perfaud/field_roles.py +128 -0
- perfaud/financial_integrity.py +453 -0
- perfaud/holdings.py +214 -0
- perfaud/lineage.py +524 -0
- perfaud/output_integrity.py +656 -0
- perfaud/output_policy.py +96 -0
- perfaud/paths.py +55 -0
- perfaud/period_linking.py +313 -0
- perfaud/portfolio_performance.py +114 -0
- perfaud/py.typed +0 -0
- perfaud/rendering.py +702 -0
- perfaud/report.py +1547 -0
- perfaud/review.py +275 -0
- perfaud/runner.py +448 -0
- perfaud/safety_invariants.py +370 -0
- perfaud/schema.py +304 -0
- perfaud/security_master.py +118 -0
- perfaud/security_performance.py +134 -0
- perfaud/source_data_contract.py +172 -0
- perfaud/source_loader.py +777 -0
- perfaud/specification.py +856 -0
- perfaud/splits.py +87 -0
- perfaud/templates/axys_apx/README.md +107 -0
- perfaud/templates/axys_apx/demo_extract_availability.yaml +1316 -0
- perfaud/templates/axys_apx/input/snapshot_a/holdings.csv +467 -0
- perfaud/templates/axys_apx/input/snapshot_a/portperf.csv +31 -0
- perfaud/templates/axys_apx/input/snapshot_a/secmast.csv +23 -0
- perfaud/templates/axys_apx/input/snapshot_a/secperf.csv +467 -0
- perfaud/templates/axys_apx/input/snapshot_a/splits.csv +1 -0
- perfaud/templates/axys_apx/input/snapshot_a/transactions.csv +37 -0
- perfaud/templates/axys_apx/input/snapshot_b/holdings.csv +467 -0
- perfaud/templates/axys_apx/input/snapshot_b/portperf.csv +31 -0
- perfaud/templates/axys_apx/input/snapshot_b/secmast.csv +23 -0
- perfaud/templates/axys_apx/input/snapshot_b/secperf.csv +467 -0
- perfaud/templates/axys_apx/input/snapshot_b/splits.csv +2 -0
- perfaud/templates/axys_apx/input/snapshot_b/transactions.csv +50 -0
- perfaud/templates/axys_apx/perfaud.yaml +732 -0
- perfaud/transaction_codes.py +21 -0
- perfaud/transaction_summary.py +166 -0
- perfaud/transactions.py +907 -0
- perfaud/workbook/__init__.py +1 -0
- perfaud/workbook/formula_rows.py +496 -0
- perfaud/workbook/guidance.py +962 -0
- perfaud/workbook/layout.py +488 -0
- perfaud/workbook/reconstruction.py +173 -0
- perfaud/workbook/rows.py +257 -0
- perfaud/workbook/source_allocation.py +474 -0
- perfaud/workbook/tables.py +2678 -0
- perfaud/workbook/writer.py +1046 -0
- perfaud/workspace.py +175 -0
- perfaud-0.1.0.dist-info/METADATA +94 -0
- perfaud-0.1.0.dist-info/RECORD +90 -0
- perfaud-0.1.0.dist-info/WHEEL +5 -0
- perfaud-0.1.0.dist-info/entry_points.txt +2 -0
- perfaud-0.1.0.dist-info/licenses/LICENSE +138 -0
- perfaud-0.1.0.dist-info/top_level.txt +1 -0
perfaud/__init__.py
ADDED
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
|
+
)
|