indiciumforge-core 2.0.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 (64) hide show
  1. indiciumforge_core/__init__.py +1 -0
  2. indiciumforge_core/artifacts/__init__.py +1 -0
  3. indiciumforge_core/artifacts/comparator.py +80 -0
  4. indiciumforge_core/artifacts/formatting.py +21 -0
  5. indiciumforge_core/artifacts/local_store.py +14 -0
  6. indiciumforge_core/artifacts/manifest.py +583 -0
  7. indiciumforge_core/artifacts/paths.py +41 -0
  8. indiciumforge_core/artifacts/writer.py +73 -0
  9. indiciumforge_core/domain/__init__.py +1 -0
  10. indiciumforge_core/domain/models.py +78 -0
  11. indiciumforge_core/factors/__init__.py +54 -0
  12. indiciumforge_core/factors/artifacts.py +111 -0
  13. indiciumforge_core/factors/demo/__init__.py +7 -0
  14. indiciumforge_core/factors/demo/quiet_accumulation.py +65 -0
  15. indiciumforge_core/factors/demo/volume_breakout.py +60 -0
  16. indiciumforge_core/factors/loading.py +101 -0
  17. indiciumforge_core/factors/models.py +24 -0
  18. indiciumforge_core/factors/pack.py +139 -0
  19. indiciumforge_core/factors/ports.py +22 -0
  20. indiciumforge_core/factors/registry.py +74 -0
  21. indiciumforge_core/factors/scan.py +57 -0
  22. indiciumforge_core/factors/universe.py +59 -0
  23. indiciumforge_core/labels/__init__.py +1 -0
  24. indiciumforge_core/labels/market_gate.py +72 -0
  25. indiciumforge_core/market/theme_rules.py +28 -0
  26. indiciumforge_core/parity/__init__.py +41 -0
  27. indiciumforge_core/parity/comparator.py +250 -0
  28. indiciumforge_core/parity/config.py +128 -0
  29. indiciumforge_core/parity/harness.py +53 -0
  30. indiciumforge_core/parity/models.py +81 -0
  31. indiciumforge_core/parity/ports.py +34 -0
  32. indiciumforge_core/parity/reference.py +53 -0
  33. indiciumforge_core/parity/schemas.py +3 -0
  34. indiciumforge_core/ports/__init__.py +1 -0
  35. indiciumforge_core/ports/contracts.py +32 -0
  36. indiciumforge_core/providers/__init__.py +18 -0
  37. indiciumforge_core/providers/capabilities.py +54 -0
  38. indiciumforge_core/providers/config.py +223 -0
  39. indiciumforge_core/providers/contracts_v2.py +17 -0
  40. indiciumforge_core/providers/local_fixture.py +69 -0
  41. indiciumforge_core/providers/local_fixture_v2.py +125 -0
  42. indiciumforge_core/providers/pack.py +15 -0
  43. indiciumforge_core/providers/query.py +93 -0
  44. indiciumforge_core/providers/registry.py +37 -0
  45. indiciumforge_core/providers/registry_v2.py +182 -0
  46. indiciumforge_core/providers/result.py +77 -0
  47. indiciumforge_core/recipes/__init__.py +28 -0
  48. indiciumforge_core/recipes/config.py +137 -0
  49. indiciumforge_core/recipes/models.py +64 -0
  50. indiciumforge_core/recipes/pack.py +96 -0
  51. indiciumforge_core/recipes/ports.py +35 -0
  52. indiciumforge_core/recipes/resolver.py +87 -0
  53. indiciumforge_core/recipes/runner.py +193 -0
  54. indiciumforge_core/recipes/schemas.py +3 -0
  55. indiciumforge_core/schema_compat.py +35 -0
  56. indiciumforge_core/text.py +31 -0
  57. indiciumforge_core/workflow/__init__.py +51 -0
  58. indiciumforge_core/workflow/handoff.py +26 -0
  59. indiciumforge_core/workflow/model.py +112 -0
  60. indiciumforge_core/workflow/recipe_schema.py +124 -0
  61. indiciumforge_core-2.0.0.dist-info/METADATA +42 -0
  62. indiciumforge_core-2.0.0.dist-info/RECORD +64 -0
  63. indiciumforge_core-2.0.0.dist-info/WHEEL +5 -0
  64. indiciumforge_core-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ """Core contracts and artifacts for IndiciumForge."""
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import pandas as pd
8
+
9
+ from indiciumforge_core.labels.market_gate import REVIEW_COLUMNS
10
+ from indiciumforge_core.text import normalize_code_series
11
+
12
+ GATE_ARTIFACTS = (
13
+ "market_gated_candidates.csv",
14
+ "market_gated_observation.csv",
15
+ "market_gated_active_watch.csv",
16
+ "market_gated_rejected.csv",
17
+ "market_gate_calibration_audit.json",
18
+ "market_gate_summary.json",
19
+ "market_gate_state.json",
20
+ )
21
+
22
+
23
+ def assert_schema_exists(actual_dir: Path, expected_dir: Path) -> None:
24
+ for name in GATE_ARTIFACTS:
25
+ assert (expected_dir / name).exists(), f"missing expected artifact {name}"
26
+ if name.endswith(".json"):
27
+ continue
28
+ assert (actual_dir / name).exists(), f"missing actual artifact {name}"
29
+
30
+
31
+ def compare_semantic_market_gate(actual_dir: Path, expected_dir: Path) -> None:
32
+ assert_schema_exists(actual_dir, expected_dir)
33
+ for stem in (
34
+ "market_gated_candidates",
35
+ "market_gated_observation",
36
+ "market_gated_active_watch",
37
+ "market_gated_rejected",
38
+ ):
39
+ actual = pd.read_csv(actual_dir / f"{stem}.csv", encoding="utf-8-sig")
40
+ expected = pd.read_csv(expected_dir / f"{stem}.csv", encoding="utf-8-sig")
41
+ assert list(actual.columns) == list(expected.columns), stem
42
+ if REVIEW_COLUMNS["code"] in actual.columns:
43
+ assert set(normalize_code_series(actual[REVIEW_COLUMNS["code"]])) == set(
44
+ normalize_code_series(expected[REVIEW_COLUMNS["code"]])
45
+ ), stem
46
+ for json_name in ("market_gate_calibration_audit.json", "market_gate_summary.json"):
47
+ actual_payload = json.loads((actual_dir / json_name).read_text(encoding="utf-8-sig"))
48
+ expected_payload = json.loads((expected_dir / json_name).read_text(encoding="utf-8-sig"))
49
+ for key in (
50
+ "strict_count",
51
+ "observation_count",
52
+ "watch_count",
53
+ "rejected_count",
54
+ "candidate_count",
55
+ "quality_gate_warning",
56
+ ):
57
+ if key in expected_payload:
58
+ assert actual_payload.get(key) == expected_payload.get(key), f"{json_name}:{key}"
59
+ actual_state = json.loads(
60
+ (actual_dir / "market_gate_state.json").read_text(encoding="utf-8-sig")
61
+ )
62
+ expected_state = json.loads(
63
+ (expected_dir / "market_gate_state.json").read_text(encoding="utf-8-sig")
64
+ )
65
+ for key in (
66
+ "workflow_review_source_stage",
67
+ "strict_count",
68
+ "observation_count",
69
+ "watch_count",
70
+ "rejected_count",
71
+ "candidate_count",
72
+ "quality_gate_warning",
73
+ ):
74
+ assert actual_state.get(key) == expected_state.get(key), f"state:{key}"
75
+ if expected_state.get("warnings"):
76
+ assert actual_state.get("warnings"), "state warnings missing"
77
+
78
+
79
+ def load_meta(path: Path) -> dict[str, Any]:
80
+ return json.loads(path.read_text(encoding="utf-8-sig"))
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+ from indiciumforge_core.labels.market_gate import REVIEW_COLUMNS
6
+
7
+
8
+ def format_code_text(frame: pd.DataFrame) -> pd.DataFrame:
9
+ column = REVIEW_COLUMNS["code"]
10
+ if column not in frame.columns:
11
+ return frame
12
+
13
+ def _format(value: object) -> str:
14
+ text = str(value).strip()
15
+ if text.startswith('="') and text.endswith('"'):
16
+ return text
17
+ return f'="{text.zfill(6)}"'
18
+
19
+ out = frame.copy()
20
+ out[column] = out[column].map(_format)
21
+ return out
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import pandas as pd
6
+
7
+
8
+ class LocalArtifactStore:
9
+ def read_csv(self, path: Path, **kwargs: object) -> pd.DataFrame:
10
+ return pd.read_csv(path, **kwargs)
11
+
12
+ def write_csv(self, path: Path, frame: pd.DataFrame) -> None:
13
+ path.parent.mkdir(parents=True, exist_ok=True)
14
+ frame.to_csv(path, index=False, encoding="utf-8-sig")
@@ -0,0 +1,583 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ from dataclasses import dataclass, field
6
+ from datetime import date
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from indiciumforge_core.artifacts.comparator import GATE_ARTIFACTS, load_meta
11
+ from indiciumforge_core.artifacts.paths import (
12
+ daily_review_dir,
13
+ factor_scan_dir,
14
+ market_gate_stage_dir,
15
+ )
16
+ from indiciumforge_core.factors.artifacts import FACTOR_SCAN_SCHEMA, FACTOR_SCAN_STATE_SCHEMA
17
+ from indiciumforge_core.labels.market_gate import MARKET_DAILY, MARKET_ZH
18
+ from indiciumforge_core.schema_compat import accepts_schema
19
+
20
+ MARKET_GATE_STAGE = "market_gate"
21
+ DAILY_REVIEW_STAGE = "daily_review"
22
+ FACTOR_SCAN_STAGE = "factor_scan"
23
+
24
+ FACTOR_SCAN_STATE_FILE = "factor_scan_state.json"
25
+
26
+ DAILY_REVIEW_REQUIRED_FILES = (
27
+ "theme_state_ranking.csv",
28
+ "market_daily_review_state.json",
29
+ )
30
+ DAILY_REVIEW_STATE_SCHEMA = "indiciumforge.market_daily_review_state.v1"
31
+
32
+ THEME_STATE_RANKING_COLUMNS: tuple[str, ...] = (
33
+ MARKET_ZH["theme_name"],
34
+ MARKET_DAILY["status"],
35
+ MARKET_DAILY["daily_state"],
36
+ MARKET_DAILY["mid_state"],
37
+ MARKET_DAILY["risk_state"],
38
+ MARKET_DAILY["divergence_state"],
39
+ )
40
+
41
+ MARKET_GATE_JSON_SCHEMAS: dict[str, str] = {
42
+ "market_gate_calibration_audit.json": (
43
+ "indiciumgrid.workflow_market_gate_calibration_audit.v1"
44
+ ),
45
+ "market_gate_summary.json": "indiciumgrid.workflow_market_gate_summary.v1",
46
+ "market_gate_state.json": "indiciumgrid.workflow.v1",
47
+ }
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class AuditViolation:
52
+ code: str
53
+ message: str
54
+ path: str | None = None
55
+
56
+
57
+ @dataclass
58
+ class ArtifactManifest:
59
+ stage: str
60
+ stage_dir: Path
61
+ trade_date: str | None
62
+ required_files: tuple[str, ...]
63
+ present_files: tuple[str, ...]
64
+ violations: list[AuditViolation] = field(default_factory=list)
65
+
66
+ @property
67
+ def ok(self) -> bool:
68
+ return not self.violations
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class MarketGateStageRef:
73
+ trade_date: str
74
+ stage_dir: Path
75
+ present_files: tuple[str, ...]
76
+
77
+ @property
78
+ def core_artifact_count(self) -> int:
79
+ return sum(1 for name in GATE_ARTIFACTS if name in self.present_files)
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class DailyReviewStageRef:
84
+ trade_date: str
85
+ stage_dir: Path
86
+ present_files: tuple[str, ...]
87
+
88
+ @property
89
+ def core_artifact_count(self) -> int:
90
+ return sum(1 for name in DAILY_REVIEW_REQUIRED_FILES if name in self.present_files)
91
+
92
+
93
+ def _load_json(path: Path) -> dict[str, Any]:
94
+ return json.loads(path.read_text(encoding="utf-8-sig"))
95
+
96
+
97
+ def _normalize_trade_date(raw: Any) -> str | None:
98
+ if raw is None:
99
+ return None
100
+ if isinstance(raw, date):
101
+ return raw.isoformat()
102
+ return str(raw)
103
+
104
+
105
+ def scan_stage_dir(stage_dir: Path) -> list[str]:
106
+ if not stage_dir.is_dir():
107
+ return []
108
+ return sorted(path.name for path in stage_dir.iterdir() if path.is_file())
109
+
110
+
111
+ def list_market_gate_stages(artifact_root: Path) -> list[MarketGateStageRef]:
112
+ workflows = artifact_root / "workflows"
113
+ if not workflows.is_dir():
114
+ return []
115
+
116
+ refs: list[MarketGateStageRef] = []
117
+ for day_dir in sorted(workflows.iterdir()):
118
+ if not day_dir.is_dir():
119
+ continue
120
+ stage_dir = day_dir / MARKET_GATE_STAGE
121
+ if not stage_dir.is_dir():
122
+ continue
123
+ raw = day_dir.name
124
+ if len(raw) == 8 and raw.isdigit():
125
+ trade_date = f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}"
126
+ else:
127
+ trade_date = raw
128
+ refs.append(
129
+ MarketGateStageRef(
130
+ trade_date=trade_date,
131
+ stage_dir=stage_dir,
132
+ present_files=tuple(scan_stage_dir(stage_dir)),
133
+ )
134
+ )
135
+ return refs
136
+
137
+
138
+ def validate_market_gate_stage(
139
+ stage_dir: Path,
140
+ *,
141
+ expected_trade_date: str | None = None,
142
+ meta_path: Path | None = None,
143
+ ) -> ArtifactManifest:
144
+ violations: list[AuditViolation] = []
145
+ present = scan_stage_dir(stage_dir)
146
+
147
+ if not stage_dir.is_dir():
148
+ violations.append(
149
+ AuditViolation("missing_stage_dir", f"stage directory not found: {stage_dir}")
150
+ )
151
+ return ArtifactManifest(
152
+ stage=MARKET_GATE_STAGE,
153
+ stage_dir=stage_dir,
154
+ trade_date=expected_trade_date,
155
+ required_files=GATE_ARTIFACTS,
156
+ present_files=tuple(present),
157
+ violations=violations,
158
+ )
159
+
160
+ for name in GATE_ARTIFACTS:
161
+ path = stage_dir / name
162
+ if not path.is_file():
163
+ violations.append(
164
+ AuditViolation(
165
+ "missing_file",
166
+ f"missing required artifact: {name}",
167
+ str(path),
168
+ )
169
+ )
170
+
171
+ trade_dates: list[str] = []
172
+ if expected_trade_date:
173
+ trade_dates.append(expected_trade_date)
174
+
175
+ if meta_path and meta_path.is_file():
176
+ meta_trade_date = _normalize_trade_date(load_meta(meta_path).get("trade_date"))
177
+ if meta_trade_date:
178
+ trade_dates.append(meta_trade_date)
179
+
180
+ for json_name, expected_schema in MARKET_GATE_JSON_SCHEMAS.items():
181
+ path = stage_dir / json_name
182
+ if not path.is_file():
183
+ continue
184
+ try:
185
+ payload = _load_json(path)
186
+ except json.JSONDecodeError as exc:
187
+ violations.append(
188
+ AuditViolation("invalid_json", f"{json_name}: {exc}", str(path))
189
+ )
190
+ continue
191
+
192
+ actual_schema = payload.get("schema")
193
+ if not accepts_schema(actual_schema, expected_schema, context=json_name):
194
+ violations.append(
195
+ AuditViolation(
196
+ "schema_mismatch",
197
+ (
198
+ f"{json_name}: expected schema {expected_schema!r}, "
199
+ f"got {actual_schema!r}"
200
+ ),
201
+ str(path),
202
+ )
203
+ )
204
+
205
+ if json_name == "market_gate_state.json" and payload.get("stage") != MARKET_GATE_STAGE:
206
+ violations.append(
207
+ AuditViolation(
208
+ "invalid_stage",
209
+ (
210
+ f"state.stage must be {MARKET_GATE_STAGE!r}, "
211
+ f"got {payload.get('stage')!r}"
212
+ ),
213
+ str(path),
214
+ )
215
+ )
216
+
217
+ trade_date = _normalize_trade_date(payload.get("trade_date"))
218
+ if trade_date:
219
+ trade_dates.append(trade_date)
220
+
221
+ unique_dates = {value for value in trade_dates if value}
222
+ if len(unique_dates) > 1:
223
+ violations.append(
224
+ AuditViolation(
225
+ "trade_date_mismatch",
226
+ f"inconsistent trade_date values: {sorted(unique_dates)}",
227
+ )
228
+ )
229
+
230
+ resolved_trade_date = next(iter(unique_dates)) if len(unique_dates) == 1 else None
231
+
232
+ return ArtifactManifest(
233
+ stage=MARKET_GATE_STAGE,
234
+ stage_dir=stage_dir,
235
+ trade_date=resolved_trade_date or expected_trade_date,
236
+ required_files=GATE_ARTIFACTS,
237
+ present_files=tuple(present),
238
+ violations=violations,
239
+ )
240
+
241
+
242
+ def resolve_market_gate_audit_target(
243
+ *,
244
+ artifact_root: Path | None,
245
+ trade_date: date | None,
246
+ stage_dir: Path | None,
247
+ ) -> tuple[Path, str | None]:
248
+ if stage_dir is not None:
249
+ return stage_dir, None
250
+ if artifact_root is None or trade_date is None:
251
+ raise ValueError("provide --stage-dir or both --artifact-root and --trade-date")
252
+ return market_gate_stage_dir(artifact_root, trade_date), trade_date.isoformat()
253
+
254
+
255
+ def list_daily_review_stages(artifact_root: Path) -> list[DailyReviewStageRef]:
256
+ awareness = artifact_root / "market_awareness"
257
+ if not awareness.is_dir():
258
+ return []
259
+
260
+ refs: list[DailyReviewStageRef] = []
261
+ for day_dir in sorted(awareness.iterdir()):
262
+ if not day_dir.is_dir():
263
+ continue
264
+ stage_dir = day_dir / DAILY_REVIEW_STAGE
265
+ if not stage_dir.is_dir():
266
+ continue
267
+ raw = day_dir.name
268
+ if len(raw) == 8 and raw.isdigit():
269
+ trade_date = f"{raw[:4]}-{raw[4:6]}-{raw[6:8]}"
270
+ else:
271
+ trade_date = raw
272
+ refs.append(
273
+ DailyReviewStageRef(
274
+ trade_date=trade_date,
275
+ stage_dir=stage_dir,
276
+ present_files=tuple(scan_stage_dir(stage_dir)),
277
+ )
278
+ )
279
+ return refs
280
+
281
+
282
+ def _read_csv_header(path: Path) -> list[str] | None:
283
+ if not path.is_file():
284
+ return None
285
+ with path.open(encoding="utf-8-sig", newline="") as handle:
286
+ reader = csv.reader(handle)
287
+ return next(reader, None)
288
+
289
+
290
+ def validate_daily_review_stage(
291
+ stage_dir: Path,
292
+ *,
293
+ expected_trade_date: str | None = None,
294
+ ) -> ArtifactManifest:
295
+ violations: list[AuditViolation] = []
296
+ present = scan_stage_dir(stage_dir)
297
+
298
+ if not stage_dir.is_dir():
299
+ violations.append(
300
+ AuditViolation("missing_stage_dir", f"stage directory not found: {stage_dir}")
301
+ )
302
+ return ArtifactManifest(
303
+ stage=DAILY_REVIEW_STAGE,
304
+ stage_dir=stage_dir,
305
+ trade_date=expected_trade_date,
306
+ required_files=DAILY_REVIEW_REQUIRED_FILES,
307
+ present_files=tuple(present),
308
+ violations=violations,
309
+ )
310
+
311
+ for name in DAILY_REVIEW_REQUIRED_FILES:
312
+ path = stage_dir / name
313
+ if not path.is_file():
314
+ violations.append(
315
+ AuditViolation(
316
+ "missing_file",
317
+ f"missing required artifact: {name}",
318
+ str(path),
319
+ )
320
+ )
321
+
322
+ ranking_path = stage_dir / "theme_state_ranking.csv"
323
+ if ranking_path.is_file():
324
+ header = _read_csv_header(ranking_path)
325
+ if header is None:
326
+ violations.append(
327
+ AuditViolation(
328
+ "invalid_csv",
329
+ "theme_state_ranking.csv: empty or unreadable header",
330
+ str(ranking_path),
331
+ )
332
+ )
333
+ elif tuple(header) != THEME_STATE_RANKING_COLUMNS:
334
+ violations.append(
335
+ AuditViolation(
336
+ "csv_column_mismatch",
337
+ (
338
+ "theme_state_ranking.csv: expected columns "
339
+ f"{list(THEME_STATE_RANKING_COLUMNS)!r}, got {header!r}"
340
+ ),
341
+ str(ranking_path),
342
+ )
343
+ )
344
+
345
+ trade_dates: list[str] = []
346
+ if expected_trade_date:
347
+ trade_dates.append(expected_trade_date)
348
+
349
+ state_path = stage_dir / "market_daily_review_state.json"
350
+ if state_path.is_file():
351
+ try:
352
+ payload = _load_json(state_path)
353
+ except json.JSONDecodeError as exc:
354
+ violations.append(
355
+ AuditViolation(
356
+ "invalid_json",
357
+ f"market_daily_review_state.json: {exc}",
358
+ str(state_path),
359
+ )
360
+ )
361
+ else:
362
+ actual_schema = payload.get("schema")
363
+ if not accepts_schema(
364
+ actual_schema, DAILY_REVIEW_STATE_SCHEMA, context="market_daily_review_state.json"
365
+ ):
366
+ violations.append(
367
+ AuditViolation(
368
+ "schema_mismatch",
369
+ (
370
+ "market_daily_review_state.json: expected schema "
371
+ f"{DAILY_REVIEW_STATE_SCHEMA!r}, got {actual_schema!r}"
372
+ ),
373
+ str(state_path),
374
+ )
375
+ )
376
+ trade_date = _normalize_trade_date(payload.get("trade_date"))
377
+ if trade_date:
378
+ trade_dates.append(trade_date)
379
+
380
+ unique_dates = {value for value in trade_dates if value}
381
+ if len(unique_dates) > 1:
382
+ violations.append(
383
+ AuditViolation(
384
+ "trade_date_mismatch",
385
+ f"inconsistent trade_date values: {sorted(unique_dates)}",
386
+ )
387
+ )
388
+
389
+ resolved_trade_date = next(iter(unique_dates)) if len(unique_dates) == 1 else None
390
+
391
+ return ArtifactManifest(
392
+ stage=DAILY_REVIEW_STAGE,
393
+ stage_dir=stage_dir,
394
+ trade_date=resolved_trade_date or expected_trade_date,
395
+ required_files=DAILY_REVIEW_REQUIRED_FILES,
396
+ present_files=tuple(present),
397
+ violations=violations,
398
+ )
399
+
400
+
401
+ def resolve_daily_review_audit_target(
402
+ *,
403
+ artifact_root: Path | None,
404
+ trade_date: date | None,
405
+ stage_dir: Path | None,
406
+ ) -> tuple[Path, str | None]:
407
+ if stage_dir is not None:
408
+ return stage_dir, None
409
+ if artifact_root is None or trade_date is None:
410
+ raise ValueError("provide --stage-dir or both --artifact-root and --trade-date")
411
+ return daily_review_dir(artifact_root, trade_date), trade_date.isoformat()
412
+
413
+
414
+ def is_daily_review_stage_dir(stage_dir: Path) -> bool:
415
+ return stage_dir.name == DAILY_REVIEW_STAGE
416
+
417
+
418
+ def is_factor_scan_stage_dir(stage_dir: Path) -> bool:
419
+ return stage_dir.name == FACTOR_SCAN_STAGE
420
+
421
+
422
+ def _factor_scan_stem(trade_date: str) -> str:
423
+ return f"factor_scan_{trade_date.replace('-', '')}"
424
+
425
+
426
+ def validate_factor_scan_stage(
427
+ stage_dir: Path,
428
+ *,
429
+ expected_trade_date: str | None = None,
430
+ ) -> ArtifactManifest:
431
+ violations: list[AuditViolation] = []
432
+
433
+ if not stage_dir.is_dir():
434
+ violations.append(
435
+ AuditViolation("missing_dir", f"stage directory not found: {stage_dir}")
436
+ )
437
+ return ArtifactManifest(
438
+ stage=FACTOR_SCAN_STAGE,
439
+ stage_dir=stage_dir,
440
+ trade_date=expected_trade_date,
441
+ required_files=(),
442
+ present_files=(),
443
+ violations=violations,
444
+ )
445
+
446
+ present = sorted(name.name for name in stage_dir.iterdir() if name.is_file())
447
+ trade_dates: list[str] = []
448
+ if expected_trade_date:
449
+ trade_dates.append(expected_trade_date)
450
+
451
+ state_path = stage_dir / FACTOR_SCAN_STATE_FILE
452
+ if not state_path.is_file():
453
+ violations.append(
454
+ AuditViolation(
455
+ "missing_file",
456
+ f"missing required artifact: {FACTOR_SCAN_STATE_FILE}",
457
+ str(state_path),
458
+ )
459
+ )
460
+ else:
461
+ try:
462
+ state_payload = _load_json(state_path)
463
+ except json.JSONDecodeError as exc:
464
+ violations.append(
465
+ AuditViolation(
466
+ "invalid_json",
467
+ f"{FACTOR_SCAN_STATE_FILE}: {exc}",
468
+ str(state_path),
469
+ )
470
+ )
471
+ else:
472
+ actual_schema = state_payload.get("schema")
473
+ if not accepts_schema(
474
+ actual_schema, FACTOR_SCAN_STATE_SCHEMA, context=FACTOR_SCAN_STATE_FILE
475
+ ):
476
+ violations.append(
477
+ AuditViolation(
478
+ "schema_mismatch",
479
+ (
480
+ f"{FACTOR_SCAN_STATE_FILE}: expected schema "
481
+ f"{FACTOR_SCAN_STATE_SCHEMA!r}, got {actual_schema!r}"
482
+ ),
483
+ str(state_path),
484
+ )
485
+ )
486
+ trade_date = _normalize_trade_date(state_payload.get("trade_date"))
487
+ if trade_date:
488
+ trade_dates.append(trade_date)
489
+
490
+ resolved_trade_date = expected_trade_date
491
+ if trade_dates:
492
+ unique_dates = {value for value in trade_dates if value}
493
+ if len(unique_dates) > 1:
494
+ violations.append(
495
+ AuditViolation(
496
+ "trade_date_mismatch",
497
+ f"inconsistent trade_date values: {sorted(unique_dates)}",
498
+ )
499
+ )
500
+ elif len(unique_dates) == 1:
501
+ resolved_trade_date = next(iter(unique_dates))
502
+
503
+ stem = _factor_scan_stem(resolved_trade_date or "unknown")
504
+ json_name = f"{stem}.json"
505
+ csv_name = f"{stem}.csv"
506
+ required_files = (FACTOR_SCAN_STATE_FILE, json_name, csv_name)
507
+
508
+ for name in (json_name, csv_name):
509
+ path = stage_dir / name
510
+ if not path.is_file():
511
+ violations.append(
512
+ AuditViolation(
513
+ "missing_file",
514
+ f"missing required artifact: {name}",
515
+ str(path),
516
+ )
517
+ )
518
+
519
+ json_path = stage_dir / json_name
520
+ if json_path.is_file():
521
+ try:
522
+ scan_payload = _load_json(json_path)
523
+ except json.JSONDecodeError as exc:
524
+ violations.append(
525
+ AuditViolation(
526
+ "invalid_json",
527
+ f"{json_name}: {exc}",
528
+ str(json_path),
529
+ )
530
+ )
531
+ else:
532
+ actual_schema = scan_payload.get("schema")
533
+ if not accepts_schema(actual_schema, FACTOR_SCAN_SCHEMA, context=json_name):
534
+ violations.append(
535
+ AuditViolation(
536
+ "schema_mismatch",
537
+ (
538
+ f"{json_name}: expected schema {FACTOR_SCAN_SCHEMA!r}, "
539
+ f"got {actual_schema!r}"
540
+ ),
541
+ str(json_path),
542
+ )
543
+ )
544
+
545
+ return ArtifactManifest(
546
+ stage=FACTOR_SCAN_STAGE,
547
+ stage_dir=stage_dir,
548
+ trade_date=resolved_trade_date,
549
+ required_files=required_files,
550
+ present_files=tuple(present),
551
+ violations=violations,
552
+ )
553
+
554
+
555
+ def resolve_factor_scan_audit_target(
556
+ *,
557
+ artifact_root: Path | None,
558
+ trade_date: date | None,
559
+ stage_dir: Path | None,
560
+ ) -> tuple[Path, str | None]:
561
+ if stage_dir is not None:
562
+ return stage_dir, None
563
+ if artifact_root is None or trade_date is None:
564
+ raise ValueError("provide --stage-dir or both --artifact-root and --trade-date")
565
+ return factor_scan_dir(artifact_root, trade_date), trade_date.isoformat()
566
+
567
+
568
+ def format_audit_report(manifest: ArtifactManifest) -> str:
569
+ lines = [
570
+ f"stage: {manifest.stage}",
571
+ f"dir: {manifest.stage_dir}",
572
+ f"trade_date: {manifest.trade_date or '(unknown)'}",
573
+ f"required: {len(manifest.required_files)}",
574
+ f"present: {len(manifest.present_files)}",
575
+ ]
576
+ if manifest.ok:
577
+ lines.append("status: ok")
578
+ else:
579
+ lines.append("status: failed")
580
+ for violation in manifest.violations:
581
+ location = f" ({violation.path})" if violation.path else ""
582
+ lines.append(f" [{violation.code}] {violation.message}{location}")
583
+ return "\n".join(lines)