indiciumforge-workflow 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 (28) hide show
  1. indiciumforge_workflow/__init__.py +1 -0
  2. indiciumforge_workflow/e2e/__init__.py +1 -0
  3. indiciumforge_workflow/e2e/synthetic.py +155 -0
  4. indiciumforge_workflow/factor_scan/__init__.py +11 -0
  5. indiciumforge_workflow/factor_scan/runner.py +77 -0
  6. indiciumforge_workflow/market_awareness/__init__.py +35 -0
  7. indiciumforge_workflow/market_awareness/artifacts.py +70 -0
  8. indiciumforge_workflow/market_awareness/classifier.py +121 -0
  9. indiciumforge_workflow/market_awareness/fixtures.py +57 -0
  10. indiciumforge_workflow/market_awareness/models.py +39 -0
  11. indiciumforge_workflow/market_awareness/runner.py +51 -0
  12. indiciumforge_workflow/market_gate/__init__.py +1 -0
  13. indiciumforge_workflow/market_gate/active_watch.py +85 -0
  14. indiciumforge_workflow/market_gate/calibration.py +74 -0
  15. indiciumforge_workflow/market_gate/helpers.py +92 -0
  16. indiciumforge_workflow/market_gate/kernel.py +173 -0
  17. indiciumforge_workflow/market_gate/render.py +110 -0
  18. indiciumforge_workflow/market_gate/resolver.py +51 -0
  19. indiciumforge_workflow/market_gate/runner.py +157 -0
  20. indiciumforge_workflow/parity/__init__.py +1 -0
  21. indiciumforge_workflow/parity/runner.py +54 -0
  22. indiciumforge_workflow/workflow_chain/__init__.py +1 -0
  23. indiciumforge_workflow/workflow_chain/runner.py +533 -0
  24. indiciumforge_workflow/workflow_chain/skeleton.py +76 -0
  25. indiciumforge_workflow-2.0.0.dist-info/METADATA +44 -0
  26. indiciumforge_workflow-2.0.0.dist-info/RECORD +28 -0
  27. indiciumforge_workflow-2.0.0.dist-info/WHEEL +5 -0
  28. indiciumforge_workflow-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ """Workflow services for IndiciumForge."""
@@ -0,0 +1 @@
1
+ """Synthetic end-to-end workflow orchestration."""
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import shutil
5
+ from dataclasses import dataclass
6
+ from datetime import date
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from indiciumforge_core.artifacts.manifest import (
11
+ validate_daily_review_stage,
12
+ validate_market_gate_stage,
13
+ )
14
+ from indiciumforge_core.artifacts.paths import (
15
+ daily_review_dir,
16
+ market_gate_stage_dir,
17
+ synthetic_e2e_summary_path,
18
+ workflow_root,
19
+ )
20
+
21
+ from indiciumforge_workflow.market_awareness.runner import run_daily_review_skeleton
22
+ from indiciumforge_workflow.market_gate.runner import run_market_gate
23
+
24
+ SYNTHETIC_E2E_SUMMARY_SCHEMA = "indiciumforge.synthetic_e2e_summary.v1"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class SyntheticE2EResult:
29
+ trade_date: date
30
+ daily_review_stage_dir: Path
31
+ market_gate_stage_dir: Path
32
+ summary_path: Path
33
+ daily_review_audit_ok: bool
34
+ market_gate_audit_ok: bool
35
+ audit_ok: bool
36
+ warnings: tuple[str, ...]
37
+
38
+
39
+ def _seed_preopen_review(
40
+ artifact_root: Path,
41
+ trade_date: date,
42
+ preopen_review_fixture: Path,
43
+ ) -> Path:
44
+ target_dir = workflow_root(artifact_root, trade_date) / "preopen"
45
+ target_dir.mkdir(parents=True, exist_ok=True)
46
+ target_path = target_dir / "buy_point_review_internal.csv"
47
+ shutil.copy2(preopen_review_fixture, target_path)
48
+ return target_path
49
+
50
+
51
+ def _write_summary(
52
+ path: Path,
53
+ *,
54
+ trade_date: date,
55
+ daily_review_stage_dir: Path,
56
+ market_gate_stage_dir: Path,
57
+ daily_review_audit_ok: bool,
58
+ market_gate_audit_ok: bool,
59
+ daily_review_fixture: Path,
60
+ preopen_review_fixture: Path,
61
+ warnings: list[str],
62
+ ) -> None:
63
+ payload: dict[str, Any] = {
64
+ "schema": SYNTHETIC_E2E_SUMMARY_SCHEMA,
65
+ "trade_date": trade_date.isoformat(),
66
+ "provenance": {
67
+ "mode": "synthetic_e2e",
68
+ "fixtures": {
69
+ "daily_review": str(daily_review_fixture),
70
+ "preopen_review": str(preopen_review_fixture),
71
+ },
72
+ },
73
+ "stages": {
74
+ "daily_review": {
75
+ "dir": str(daily_review_stage_dir),
76
+ "audit_ok": daily_review_audit_ok,
77
+ },
78
+ "market_gate": {
79
+ "dir": str(market_gate_stage_dir),
80
+ "audit_ok": market_gate_audit_ok,
81
+ },
82
+ },
83
+ "audit_ok": daily_review_audit_ok and market_gate_audit_ok,
84
+ "warnings": warnings,
85
+ }
86
+ path.parent.mkdir(parents=True, exist_ok=True)
87
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig")
88
+
89
+
90
+ def run_synthetic_e2e(
91
+ *,
92
+ trade_date: date,
93
+ artifact_root: Path,
94
+ daily_review_fixture: Path,
95
+ preopen_review_fixture: Path,
96
+ ) -> SyntheticE2EResult:
97
+ if not daily_review_fixture.is_file():
98
+ raise FileNotFoundError(f"missing daily-review fixture: {daily_review_fixture}")
99
+ if not preopen_review_fixture.is_file():
100
+ raise FileNotFoundError(f"missing preopen review fixture: {preopen_review_fixture}")
101
+
102
+ warnings: list[str] = []
103
+ trade_date_iso = trade_date.isoformat()
104
+
105
+ _seed_preopen_review(artifact_root, trade_date, preopen_review_fixture)
106
+
107
+ daily_result = run_daily_review_skeleton(
108
+ trade_date=trade_date,
109
+ artifact_root=artifact_root,
110
+ fixture_path=daily_review_fixture,
111
+ )
112
+ warnings.extend(daily_result.warnings)
113
+
114
+ gate_result = run_market_gate(trade_date=trade_date, artifact_root=artifact_root)
115
+ warnings.extend(gate_result.warnings)
116
+
117
+ dr_stage_dir = daily_review_dir(artifact_root, trade_date)
118
+ mg_stage_dir = market_gate_stage_dir(artifact_root, trade_date)
119
+
120
+ daily_manifest = validate_daily_review_stage(
121
+ dr_stage_dir,
122
+ expected_trade_date=trade_date_iso,
123
+ )
124
+ gate_manifest = validate_market_gate_stage(
125
+ mg_stage_dir,
126
+ expected_trade_date=trade_date_iso,
127
+ )
128
+
129
+ daily_review_audit_ok = daily_manifest.ok
130
+ market_gate_audit_ok = gate_manifest.ok
131
+ audit_ok = daily_review_audit_ok and market_gate_audit_ok
132
+
133
+ summary_path = synthetic_e2e_summary_path(artifact_root, trade_date)
134
+ _write_summary(
135
+ summary_path,
136
+ trade_date=trade_date,
137
+ daily_review_stage_dir=dr_stage_dir,
138
+ market_gate_stage_dir=mg_stage_dir,
139
+ daily_review_audit_ok=daily_review_audit_ok,
140
+ market_gate_audit_ok=market_gate_audit_ok,
141
+ daily_review_fixture=daily_review_fixture,
142
+ preopen_review_fixture=preopen_review_fixture,
143
+ warnings=warnings,
144
+ )
145
+
146
+ return SyntheticE2EResult(
147
+ trade_date=trade_date,
148
+ daily_review_stage_dir=dr_stage_dir,
149
+ market_gate_stage_dir=mg_stage_dir,
150
+ summary_path=summary_path,
151
+ daily_review_audit_ok=daily_review_audit_ok,
152
+ market_gate_audit_ok=market_gate_audit_ok,
153
+ audit_ok=audit_ok,
154
+ warnings=tuple(warnings),
155
+ )
@@ -0,0 +1,11 @@
1
+ from indiciumforge_workflow.factor_scan.runner import (
2
+ FactorScanStageConfig,
3
+ FactorScanStageResult,
4
+ run_factor_scan_stage,
5
+ )
6
+
7
+ __all__ = [
8
+ "FactorScanStageConfig",
9
+ "FactorScanStageResult",
10
+ "run_factor_scan_stage",
11
+ ]
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+ from indiciumforge_core.artifacts.paths import factor_scan_dir
8
+ from indiciumforge_core.domain.models import AssetID
9
+ from indiciumforge_core.factors.artifacts import write_factor_scan_bundle, write_factor_scan_state
10
+ from indiciumforge_core.factors.pack import LoadedFactorPack, load_factor_pack
11
+ from indiciumforge_core.factors.scan import FactorScanRunner
12
+ from indiciumforge_core.providers.local_fixture import LocalFixtureProvider
13
+ from indiciumforge_core.providers.registry import ProviderRegistry
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class FactorScanStageConfig:
18
+ pack_config: Path | None = None
19
+ detectors_config: Path | None = None
20
+ include_entry_points: bool = False
21
+ ohlcv_fixture_root: Path | None = None
22
+ asset_fixture_list: Path | None = None
23
+ assets: tuple[AssetID, ...] = ()
24
+ asset_universe_source: str = "fixture_asset_list"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class FactorScanStageResult:
29
+ stage_dir: Path
30
+ pack: LoadedFactorPack
31
+ signal_count: int
32
+ detector_count: int
33
+ warnings: tuple[str, ...]
34
+
35
+
36
+ def run_factor_scan_stage(
37
+ *,
38
+ trade_date: date,
39
+ artifact_root: Path,
40
+ config: FactorScanStageConfig,
41
+ ) -> FactorScanStageResult:
42
+ if not config.assets:
43
+ raise ValueError("factor scan stage requires at least one asset")
44
+
45
+ if config.ohlcv_fixture_root is None:
46
+ raise ValueError("factor scan stage requires ohlcv_fixture_root")
47
+
48
+ loaded = load_factor_pack(
49
+ pack_config=config.pack_config,
50
+ detectors_config=config.detectors_config,
51
+ include_entry_points=config.include_entry_points,
52
+ )
53
+ provider_registry = ProviderRegistry(
54
+ [LocalFixtureProvider(config.ohlcv_fixture_root)]
55
+ )
56
+ runner = FactorScanRunner(provider_registry, loaded.registry)
57
+ result = runner.scan(list(config.assets), trade_date)
58
+
59
+ stage_dir = factor_scan_dir(artifact_root, trade_date)
60
+ write_factor_scan_bundle(stage_dir, trade_date, result)
61
+ write_factor_scan_state(
62
+ stage_dir,
63
+ trade_date=trade_date,
64
+ pack_id=loaded.pack_id,
65
+ detector_names=loaded.registry.list_detectors(),
66
+ asset_universe_source=config.asset_universe_source,
67
+ signal_count=len(result.signals),
68
+ warning_count=len(result.warnings),
69
+ )
70
+
71
+ return FactorScanStageResult(
72
+ stage_dir=stage_dir,
73
+ pack=loaded,
74
+ signal_count=len(result.signals),
75
+ detector_count=len(loaded.registry.list_detectors()),
76
+ warnings=result.warnings,
77
+ )
@@ -0,0 +1,35 @@
1
+ from indiciumforge_workflow.market_awareness.artifacts import (
2
+ THEME_STATE_RANKING_COLUMNS,
3
+ theme_state_rows_to_frame,
4
+ write_daily_review_state,
5
+ write_theme_state_ranking,
6
+ )
7
+ from indiciumforge_workflow.market_awareness.classifier import (
8
+ classify_theme_state,
9
+ classify_theme_states,
10
+ )
11
+ from indiciumforge_workflow.market_awareness.fixtures import (
12
+ ThemeFixtureLoadError,
13
+ load_theme_sector_fixture,
14
+ )
15
+ from indiciumforge_workflow.market_awareness.models import (
16
+ DailyReviewResult,
17
+ ThemeSectorMetrics,
18
+ ThemeStateRow,
19
+ )
20
+ from indiciumforge_workflow.market_awareness.runner import run_daily_review_skeleton
21
+
22
+ __all__ = [
23
+ "DailyReviewResult",
24
+ "THEME_STATE_RANKING_COLUMNS",
25
+ "ThemeFixtureLoadError",
26
+ "ThemeSectorMetrics",
27
+ "ThemeStateRow",
28
+ "classify_theme_state",
29
+ "classify_theme_states",
30
+ "load_theme_sector_fixture",
31
+ "run_daily_review_skeleton",
32
+ "theme_state_rows_to_frame",
33
+ "write_daily_review_state",
34
+ "write_theme_state_ranking",
35
+ ]
@@ -0,0 +1,70 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import date
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+ from indiciumforge_core.labels.market_gate import MARKET_DAILY, MARKET_ZH
10
+ from indiciumforge_core.market.theme_rules import THEME_STATE_RULE_VERSION, THEME_STATE_RULES
11
+
12
+ from indiciumforge_workflow.market_awareness.models import ThemeStateRow
13
+
14
+ THEME_STATE_RANKING_COLUMNS: tuple[str, ...] = (
15
+ MARKET_ZH["theme_name"],
16
+ MARKET_DAILY["status"],
17
+ MARKET_DAILY["daily_state"],
18
+ MARKET_DAILY["mid_state"],
19
+ MARKET_DAILY["risk_state"],
20
+ MARKET_DAILY["divergence_state"],
21
+ )
22
+
23
+
24
+ def theme_state_rows_to_frame(rows: list[ThemeStateRow]) -> pd.DataFrame:
25
+ records = [
26
+ {
27
+ MARKET_ZH["theme_name"]: row.theme_name,
28
+ MARKET_DAILY["status"]: row.status,
29
+ MARKET_DAILY["daily_state"]: row.daily_state,
30
+ MARKET_DAILY["mid_state"]: row.mid_state,
31
+ MARKET_DAILY["risk_state"]: row.risk_state,
32
+ MARKET_DAILY["divergence_state"]: row.divergence_state,
33
+ }
34
+ for row in rows
35
+ ]
36
+ if not records:
37
+ return pd.DataFrame(columns=list(THEME_STATE_RANKING_COLUMNS))
38
+ return pd.DataFrame(records)
39
+
40
+
41
+ def write_theme_state_ranking(path: Path, rows: list[ThemeStateRow]) -> Path:
42
+ path.parent.mkdir(parents=True, exist_ok=True)
43
+ frame = theme_state_rows_to_frame(rows)
44
+ frame.to_csv(path, index=False, encoding="utf-8-sig")
45
+ return path
46
+
47
+
48
+ def write_daily_review_state(
49
+ path: Path,
50
+ *,
51
+ trade_date: date,
52
+ theme_state_ranking_path: Path,
53
+ fixture_name: str,
54
+ warnings: list[str] | None = None,
55
+ ) -> Path:
56
+ payload: dict[str, Any] = {
57
+ "schema": "indiciumforge.market_daily_review_state.v1",
58
+ "trade_date": trade_date.isoformat(),
59
+ "theme_state_rule_version": THEME_STATE_RULE_VERSION,
60
+ "theme_state_rules": dict(THEME_STATE_RULES),
61
+ "paths": {"theme_state_ranking": str(theme_state_ranking_path)},
62
+ "warnings": warnings or [],
63
+ "provenance": {
64
+ "source": "synthetic_fixture",
65
+ "fixture": fixture_name,
66
+ },
67
+ }
68
+ path.parent.mkdir(parents=True, exist_ok=True)
69
+ path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig")
70
+ return path
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from indiciumforge_core.labels.market_gate import MARKET_DAILY, MARKET_ZH
4
+ from indiciumforge_core.market.theme_rules import THEME_STATE_RULES
5
+
6
+ from indiciumforge_workflow.market_awareness.models import ThemeSectorMetrics, ThemeStateRow
7
+
8
+
9
+ def classify_theme_state(
10
+ metrics: ThemeSectorMetrics,
11
+ rules: dict[str, float | int] | None = None,
12
+ ) -> ThemeStateRow:
13
+ rule_set = rules or THEME_STATE_RULES
14
+ neutral = MARKET_ZH["neutral_theme"]
15
+ no_risk = MARKET_DAILY["normal_risk"]
16
+ no_div = MARKET_DAILY["normal_divergence"]
17
+
18
+ if _is_hard_weak(metrics, rule_set):
19
+ hard_weak = MARKET_DAILY["hard_weak"]
20
+ return ThemeStateRow(
21
+ theme_name=metrics.theme_name,
22
+ status=hard_weak,
23
+ daily_state=MARKET_DAILY["daily_weak"],
24
+ mid_state=neutral,
25
+ risk_state=hard_weak,
26
+ divergence_state=no_div,
27
+ )
28
+
29
+ if _is_divergent(metrics, rule_set):
30
+ return ThemeStateRow(
31
+ theme_name=metrics.theme_name,
32
+ status=MARKET_DAILY["divergent"],
33
+ daily_state=MARKET_DAILY["daily_weak"],
34
+ mid_state=neutral,
35
+ risk_state=no_risk,
36
+ divergence_state=MARKET_DAILY["divergent"],
37
+ )
38
+
39
+ if _is_turn_weak(metrics, rule_set):
40
+ return ThemeStateRow(
41
+ theme_name=metrics.theme_name,
42
+ status=MARKET_DAILY["mid_strong_daily_weak"],
43
+ daily_state=MARKET_DAILY["daily_weak"],
44
+ mid_state=MARKET_DAILY["mid_strong"],
45
+ risk_state=no_risk,
46
+ divergence_state=no_div,
47
+ )
48
+
49
+ if _is_daily_strong(metrics, rule_set):
50
+ daily_strong = MARKET_DAILY["daily_strong"]
51
+ return ThemeStateRow(
52
+ theme_name=metrics.theme_name,
53
+ status=daily_strong,
54
+ daily_state=daily_strong,
55
+ mid_state=neutral,
56
+ risk_state=no_risk,
57
+ divergence_state=no_div,
58
+ )
59
+
60
+ return ThemeStateRow(
61
+ theme_name=metrics.theme_name,
62
+ status=neutral,
63
+ daily_state=MARKET_DAILY["daily_weak"],
64
+ mid_state=neutral,
65
+ risk_state=no_risk,
66
+ divergence_state=no_div,
67
+ )
68
+
69
+
70
+ def classify_theme_states(
71
+ metrics_list: list[ThemeSectorMetrics],
72
+ rules: dict[str, float | int] | None = None,
73
+ ) -> list[ThemeStateRow]:
74
+ return [classify_theme_state(metrics, rules) for metrics in metrics_list]
75
+
76
+
77
+ def _is_hard_weak(metrics: ThemeSectorMetrics, rules: dict[str, float | int]) -> bool:
78
+ if metrics.sample_count < int(rules["min_sample"]):
79
+ return False
80
+ return (
81
+ metrics.median_1d <= float(rules["hard_weak_max_1d_median"])
82
+ and metrics.up_rate <= float(rules["hard_weak_max_up_rate"])
83
+ and metrics.le_minus5_ratio >= float(rules["hard_weak_min_le_minus5_ratio"])
84
+ and metrics.limit_down_count >= int(rules["hard_weak_min_limit_down_count"])
85
+ )
86
+
87
+
88
+ def _is_divergent(metrics: ThemeSectorMetrics, rules: dict[str, float | int]) -> bool:
89
+ if metrics.sample_count < int(rules["min_sample"]):
90
+ return False
91
+ return (
92
+ metrics.limit_up_count >= int(rules["divergent_min_limit_up_count"])
93
+ and metrics.ge5_ratio >= float(rules["divergent_min_ge5_ratio"])
94
+ and metrics.median_1d <= float(rules["divergent_max_1d_median"])
95
+ and metrics.up_rate <= float(rules["divergent_max_up_rate"])
96
+ and metrics.le_minus5_ratio >= float(rules["divergent_min_le_minus5_ratio"])
97
+ )
98
+
99
+
100
+ def _is_turn_weak(metrics: ThemeSectorMetrics, rules: dict[str, float | int]) -> bool:
101
+ if metrics.sample_count < int(rules["min_sample"]):
102
+ return False
103
+ mid_strong = metrics.median_3d >= float(rules["daily_strong_min_1d_median"])
104
+ return mid_strong and (
105
+ metrics.median_1d <= float(rules["turn_weak_max_1d_median"])
106
+ and metrics.up_rate <= float(rules["turn_weak_max_up_rate"])
107
+ and metrics.le_minus5_ratio >= float(rules["turn_weak_min_le_minus5_ratio"])
108
+ )
109
+
110
+
111
+ def _is_daily_strong(metrics: ThemeSectorMetrics, rules: dict[str, float | int]) -> bool:
112
+ min_sample = int(rules["strong_min_sample"])
113
+ if metrics.sample_count < min_sample:
114
+ return False
115
+ return (
116
+ metrics.median_1d >= float(rules["daily_strong_min_1d_median"])
117
+ and metrics.relative_1d >= float(rules["daily_strong_min_relative_1d"])
118
+ and metrics.up_rate >= float(rules["daily_strong_min_up_rate"])
119
+ and metrics.ge5_ratio >= float(rules["daily_strong_min_ge5_ratio"])
120
+ and metrics.limit_up_count >= int(rules["daily_strong_min_limit_up_count"])
121
+ )
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+ from indiciumforge_workflow.market_awareness.models import ThemeSectorMetrics
10
+
11
+
12
+ class ThemeFixtureLoadError(ValueError):
13
+ """Raised when a theme sector fixture cannot be parsed."""
14
+
15
+
16
+ def load_theme_sector_fixture(path: Path) -> tuple[date, list[ThemeSectorMetrics]]:
17
+ payload = yaml.safe_load(path.read_text(encoding="utf-8"))
18
+ if not isinstance(payload, dict):
19
+ raise ThemeFixtureLoadError("fixture root must be a mapping")
20
+
21
+ trade_date_raw = payload.get("trade_date")
22
+ if not trade_date_raw:
23
+ raise ThemeFixtureLoadError("fixture requires trade_date")
24
+ trade_date = date.fromisoformat(str(trade_date_raw))
25
+
26
+ themes = payload.get("themes")
27
+ if not isinstance(themes, list) or not themes:
28
+ raise ThemeFixtureLoadError("fixture requires non-empty themes list")
29
+
30
+ metrics: list[ThemeSectorMetrics] = []
31
+ for index, entry in enumerate(themes):
32
+ if not isinstance(entry, dict):
33
+ raise ThemeFixtureLoadError(f"theme entry {index} must be a mapping")
34
+ metrics.append(_parse_theme_entry(entry, index))
35
+ return trade_date, metrics
36
+
37
+
38
+ def _parse_theme_entry(entry: dict[str, Any], index: int) -> ThemeSectorMetrics:
39
+ theme_name = entry.get("theme_name")
40
+ if not theme_name:
41
+ raise ThemeFixtureLoadError(f"theme entry {index} missing theme_name")
42
+ required = ("sample_count", "median_1d", "median_3d", "up_rate")
43
+ for field in required:
44
+ if field not in entry:
45
+ raise ThemeFixtureLoadError(f"theme entry {index} missing {field}")
46
+ return ThemeSectorMetrics(
47
+ theme_name=str(theme_name),
48
+ sample_count=int(entry["sample_count"]),
49
+ median_1d=float(entry["median_1d"]),
50
+ median_3d=float(entry["median_3d"]),
51
+ up_rate=float(entry["up_rate"]),
52
+ relative_1d=float(entry.get("relative_1d", 0.0)),
53
+ ge5_ratio=float(entry.get("ge5_ratio", 0.0)),
54
+ le_minus5_ratio=float(entry.get("le_minus5_ratio", 0.0)),
55
+ limit_up_count=int(entry.get("limit_up_count", 0)),
56
+ limit_down_count=int(entry.get("limit_down_count", 0)),
57
+ )
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class ThemeSectorMetrics:
10
+ theme_name: str
11
+ sample_count: int
12
+ median_1d: float
13
+ median_3d: float
14
+ up_rate: float
15
+ relative_1d: float = 0.0
16
+ ge5_ratio: float = 0.0
17
+ le_minus5_ratio: float = 0.0
18
+ limit_up_count: int = 0
19
+ limit_down_count: int = 0
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ThemeStateRow:
24
+ theme_name: str
25
+ status: str
26
+ daily_state: str
27
+ mid_state: str
28
+ risk_state: str
29
+ divergence_state: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class DailyReviewResult:
34
+ trade_date: date
35
+ rows: tuple[ThemeStateRow, ...]
36
+ warnings: tuple[str, ...] = ()
37
+ fixture_path: Path | None = None
38
+ theme_state_ranking_path: Path | None = None
39
+ state_path: Path | None = None
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ from pathlib import Path
5
+
6
+ from indiciumforge_core.artifacts.paths import daily_review_dir, theme_state_ranking_path
7
+
8
+ from indiciumforge_workflow.market_awareness.artifacts import (
9
+ write_daily_review_state,
10
+ write_theme_state_ranking,
11
+ )
12
+ from indiciumforge_workflow.market_awareness.classifier import classify_theme_states
13
+ from indiciumforge_workflow.market_awareness.fixtures import load_theme_sector_fixture
14
+ from indiciumforge_workflow.market_awareness.models import DailyReviewResult
15
+
16
+
17
+ def run_daily_review_skeleton(
18
+ *,
19
+ trade_date: date,
20
+ artifact_root: Path,
21
+ fixture_path: Path,
22
+ ) -> DailyReviewResult:
23
+ fixture_trade_date, metrics = load_theme_sector_fixture(fixture_path)
24
+ warnings: list[str] = []
25
+ if fixture_trade_date != trade_date:
26
+ warnings.append(
27
+ f"fixture trade_date {fixture_trade_date} differs from requested {trade_date}"
28
+ )
29
+
30
+ rows = classify_theme_states(metrics)
31
+ stage_dir = daily_review_dir(artifact_root, trade_date)
32
+ ranking_path = theme_state_ranking_path(artifact_root, trade_date)
33
+ write_theme_state_ranking(ranking_path, rows)
34
+
35
+ state_path = stage_dir / "market_daily_review_state.json"
36
+ write_daily_review_state(
37
+ state_path,
38
+ trade_date=trade_date,
39
+ theme_state_ranking_path=ranking_path,
40
+ fixture_name=fixture_path.name,
41
+ warnings=warnings,
42
+ )
43
+
44
+ return DailyReviewResult(
45
+ trade_date=trade_date,
46
+ rows=tuple(rows),
47
+ warnings=tuple(warnings),
48
+ fixture_path=fixture_path,
49
+ theme_state_ranking_path=ranking_path,
50
+ state_path=state_path,
51
+ )