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.
- indiciumforge_core/__init__.py +1 -0
- indiciumforge_core/artifacts/__init__.py +1 -0
- indiciumforge_core/artifacts/comparator.py +80 -0
- indiciumforge_core/artifacts/formatting.py +21 -0
- indiciumforge_core/artifacts/local_store.py +14 -0
- indiciumforge_core/artifacts/manifest.py +583 -0
- indiciumforge_core/artifacts/paths.py +41 -0
- indiciumforge_core/artifacts/writer.py +73 -0
- indiciumforge_core/domain/__init__.py +1 -0
- indiciumforge_core/domain/models.py +78 -0
- indiciumforge_core/factors/__init__.py +54 -0
- indiciumforge_core/factors/artifacts.py +111 -0
- indiciumforge_core/factors/demo/__init__.py +7 -0
- indiciumforge_core/factors/demo/quiet_accumulation.py +65 -0
- indiciumforge_core/factors/demo/volume_breakout.py +60 -0
- indiciumforge_core/factors/loading.py +101 -0
- indiciumforge_core/factors/models.py +24 -0
- indiciumforge_core/factors/pack.py +139 -0
- indiciumforge_core/factors/ports.py +22 -0
- indiciumforge_core/factors/registry.py +74 -0
- indiciumforge_core/factors/scan.py +57 -0
- indiciumforge_core/factors/universe.py +59 -0
- indiciumforge_core/labels/__init__.py +1 -0
- indiciumforge_core/labels/market_gate.py +72 -0
- indiciumforge_core/market/theme_rules.py +28 -0
- indiciumforge_core/parity/__init__.py +41 -0
- indiciumforge_core/parity/comparator.py +250 -0
- indiciumforge_core/parity/config.py +128 -0
- indiciumforge_core/parity/harness.py +53 -0
- indiciumforge_core/parity/models.py +81 -0
- indiciumforge_core/parity/ports.py +34 -0
- indiciumforge_core/parity/reference.py +53 -0
- indiciumforge_core/parity/schemas.py +3 -0
- indiciumforge_core/ports/__init__.py +1 -0
- indiciumforge_core/ports/contracts.py +32 -0
- indiciumforge_core/providers/__init__.py +18 -0
- indiciumforge_core/providers/capabilities.py +54 -0
- indiciumforge_core/providers/config.py +223 -0
- indiciumforge_core/providers/contracts_v2.py +17 -0
- indiciumforge_core/providers/local_fixture.py +69 -0
- indiciumforge_core/providers/local_fixture_v2.py +125 -0
- indiciumforge_core/providers/pack.py +15 -0
- indiciumforge_core/providers/query.py +93 -0
- indiciumforge_core/providers/registry.py +37 -0
- indiciumforge_core/providers/registry_v2.py +182 -0
- indiciumforge_core/providers/result.py +77 -0
- indiciumforge_core/recipes/__init__.py +28 -0
- indiciumforge_core/recipes/config.py +137 -0
- indiciumforge_core/recipes/models.py +64 -0
- indiciumforge_core/recipes/pack.py +96 -0
- indiciumforge_core/recipes/ports.py +35 -0
- indiciumforge_core/recipes/resolver.py +87 -0
- indiciumforge_core/recipes/runner.py +193 -0
- indiciumforge_core/recipes/schemas.py +3 -0
- indiciumforge_core/schema_compat.py +35 -0
- indiciumforge_core/text.py +31 -0
- indiciumforge_core/workflow/__init__.py +51 -0
- indiciumforge_core/workflow/handoff.py +26 -0
- indiciumforge_core/workflow/model.py +112 -0
- indiciumforge_core/workflow/recipe_schema.py +124 -0
- indiciumforge_core-2.0.0.dist-info/METADATA +42 -0
- indiciumforge_core-2.0.0.dist-info/RECORD +64 -0
- indiciumforge_core-2.0.0.dist-info/WHEEL +5 -0
- 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)
|