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,85 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+ from indiciumforge_core.labels.market_gate import (
5
+ GATE_RESULT_OBSERVATION,
6
+ GATE_RESULT_STRICT,
7
+ MARKET_DAILY,
8
+ REVIEW_COLUMNS,
9
+ WORKFLOW_ZH,
10
+ )
11
+ from indiciumforge_core.text import clean_text, join_unique, u
12
+
13
+
14
+ def market_gate_active_watch(
15
+ row: pd.Series,
16
+ gate_result: str,
17
+ strong: list[str],
18
+ observe_reasons: list[str],
19
+ reject_reasons: list[str],
20
+ ) -> dict[str, str]:
21
+ if gate_result == GATE_RESULT_STRICT:
22
+ return {"level": u("\\u4e25\\u683c\\u6267\\u884c\\u95e8"), "reason": "", "action": ""}
23
+
24
+ stock_name = clean_text(row.get(REVIEW_COLUMNS["stock_name"]))
25
+ if "ST" in stock_name.upper() or u("\\u9000") in stock_name:
26
+ return {"level": "", "reason": "", "action": ""}
27
+
28
+ rating = clean_text(row.get(REVIEW_COLUMNS["rating"]))
29
+ short_level = clean_text(row.get(REVIEW_COLUMNS["short_level"]))
30
+ priority = clean_text(row.get(REVIEW_COLUMNS["priority_bucket"]))
31
+ mainline_strength = clean_text(row.get(REVIEW_COLUMNS["mainline_strength"]))
32
+ pattern_confidence = clean_text(row.get(REVIEW_COLUMNS["pattern_confidence"]))
33
+ narrative_stage = clean_text(row.get(REVIEW_COLUMNS["narrative_stage"]))
34
+ holding_period = clean_text(row.get(REVIEW_COLUMNS["holding_period"]))
35
+ reasons: list[str] = []
36
+
37
+ if strong:
38
+ resonance_label = u("\\u76d8\\u9762\\u5171\\u632f")
39
+ reasons.append(f"{resonance_label}={join_unique(strong)}")
40
+ if observe_reasons:
41
+ reasons.extend(observe_reasons)
42
+ if short_level == WORKFLOW_ZH["short_high"]:
43
+ reasons.append(f"{REVIEW_COLUMNS['short_level']}={short_level}")
44
+ if priority in {u("\\u91cd\\u70b9\\u76ef\\u76d8"), u("\\u89c2\\u5bdf\\u8ddf\\u8e2a")}:
45
+ reasons.append(f"{REVIEW_COLUMNS['priority_bucket']}={priority}")
46
+ if mainline_strength in {"strong_persistent", "strong_short_pulse"}:
47
+ reasons.append(f"{REVIEW_COLUMNS['mainline_strength']}={mainline_strength}")
48
+ if pattern_confidence in {u("\\u9ad8"), "high"}:
49
+ reasons.append(f"{REVIEW_COLUMNS['pattern_confidence']}={pattern_confidence}")
50
+ if rating.startswith("C-") and (
51
+ strong
52
+ or short_level == WORKFLOW_ZH["short_high"]
53
+ or mainline_strength in {"strong_persistent", "strong_short_pulse"}
54
+ ):
55
+ reasons.append(u("C\\u7ea7\\u4f46\\u5177\\u5907\\u5f3a\\u52bf/\\u627f\\u63a5\\u7ebf\\u7d22"))
56
+
57
+ hard_reject = any(
58
+ token in join_unique(reject_reasons)
59
+ for token in (
60
+ u("\\u98ce\\u9669\\u8bc1\\u5238"),
61
+ MARKET_DAILY["hard_weak"],
62
+ )
63
+ )
64
+ if hard_reject or not reasons:
65
+ return {"level": "", "reason": "", "action": ""}
66
+
67
+ if gate_result == GATE_RESULT_OBSERVATION:
68
+ level = u("\\u53ef\\u4e3b\\u52a8\\u590d\\u6838")
69
+ action = u(
70
+ "\\u89c2\\u5bdf\\u627f\\u63a5\\u4e0e\\u56de\\u8e29\\uff0c"
71
+ "\\u4e0d\\u89c6\\u4e3a\\u4e25\\u683c\\u4e70\\u70b9"
72
+ )
73
+ elif rating.startswith("C-") or holding_period == u("\\u4ec5\\u590d\\u76d8"):
74
+ level = u("\\u9057\\u6f0f\\u5ba1\\u8ba1")
75
+ action = u(
76
+ "\\u8ddf\\u8e2a\\u662f\\u5426\\u89c4\\u5219\\u8fc7\\u4e25\\uff0c"
77
+ "\\u4e0d\\u8fdb\\u5165\\u6267\\u884c\\u95e8"
78
+ )
79
+ elif narrative_stage == u("\\u540e\\u6bb5\\u62e5\\u6324"):
80
+ level = u("\\u9ad8\\u6f6e\\u4e0d\\u8ffd")
81
+ action = u("\\u53ea\\u770b\\u98ce\\u9669\\u4e0e\\u9000\\u6f6e")
82
+ else:
83
+ level = u("\\u4ec5\\u89c2\\u5bdf")
84
+ action = u("\\u4fdd\\u7559\\u89c2\\u5bdf\\uff0c\\u7b49\\u66f4\\u5f3a\\u8bc1\\u636e")
85
+ return {"level": level, "reason": join_unique(reasons), "action": action}
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+ from indiciumforge_core.labels.market_gate import MARKET_GATE_COLUMNS, REVIEW_COLUMNS
8
+ from indiciumforge_core.text import u
9
+
10
+
11
+ def market_gate_calibration_audit(
12
+ gated: pd.DataFrame,
13
+ *,
14
+ strict: pd.DataFrame,
15
+ observation: pd.DataFrame,
16
+ rejected: pd.DataFrame,
17
+ active_watch: pd.DataFrame,
18
+ trade_date: date,
19
+ ) -> dict[str, Any]:
20
+ watch_columns = [
21
+ REVIEW_COLUMNS["code"],
22
+ REVIEW_COLUMNS["stock_name"],
23
+ REVIEW_COLUMNS["rating"],
24
+ MARKET_GATE_COLUMNS["gate_result"],
25
+ MARKET_GATE_COLUMNS["active_watch_level"],
26
+ MARKET_GATE_COLUMNS["active_watch_reason"],
27
+ MARKET_GATE_COLUMNS["reject_reason"],
28
+ MARKET_GATE_COLUMNS["observe_reason"],
29
+ ]
30
+ available = [column for column in watch_columns if column in active_watch.columns]
31
+ top_missed = active_watch[available].head(30).to_dict(orient="records") if available else []
32
+ warning = ""
33
+ if len(gated) > 0 and len(strict) == 0:
34
+ warning = u(
35
+ "\\u4e25\\u683c\\u6267\\u884c\\u95e8\\u4e3a0\\uff1a\\u4e0d\\u4ee3\\u8868"
36
+ "\\u5e02\\u573a\\u6ca1\\u6709\\u673a\\u4f1a\\uff0c\\u9700\\u540c\\u65f6\\u67e5\\u770b"
37
+ "\\u4e3b\\u52a8\\u89c2\\u5bdf\\u548c\\u9057\\u6f0f\\u5ba1\\u8ba1"
38
+ )
39
+ elif len(strict) <= max(1, int(len(gated) * 0.002)) and len(gated) >= 200:
40
+ warning = u(
41
+ "\\u4e25\\u683c\\u6267\\u884c\\u95e8\\u547d\\u4e2d\\u7387\\u6781\\u4f4e\\uff1a"
42
+ "\\u9700\\u5b9a\\u671f\\u6821\\u51c6\\u89c4\\u5219\\u662f\\u5426\\u8fc7\\u4e25"
43
+ )
44
+ return {
45
+ "schema": "indiciumgrid.workflow_market_gate_calibration_audit.v1",
46
+ "trade_date": trade_date.isoformat(),
47
+ "candidate_count": int(len(gated)),
48
+ "strict_count": int(len(strict)),
49
+ "observation_count": int(len(observation)),
50
+ "watch_count": int(len(active_watch)),
51
+ "rejected_count": int(len(rejected)),
52
+ "quality_gate_warning": warning,
53
+ "outcome_status": u("\\u5f85\\u540e\\u7eed\\u6536\\u76d8\\u6570\\u636e\\u56de\\u586b"),
54
+ "future_metrics": {
55
+ "d1_return": None,
56
+ "d2_return": None,
57
+ "max_drawdown": None,
58
+ "missed_strong_move": None,
59
+ },
60
+ "top_missed_candidates": top_missed,
61
+ "notes": [
62
+ u(
63
+ "\\u672c\\u5ba1\\u8ba1\\u4e0d\\u653e\\u677emarket-gate\\u4e25\\u683c\\u6267\\u884c\\u95e8"
64
+ ),
65
+ u(
66
+ "\\u8d22\\u52a1\\u7ea2\\u65d7\\u662f\\u89c2\\u5bdf\\u98ce\\u9669\\u4e0a\\u4e0b\\u6587\\uff0c"
67
+ "\\u4e0d\\u662f\\u4e3b\\u52a8\\u89c2\\u5bdf\\u5c42\\u7684\\u4e00\\u7968\\u5426\\u51b3"
68
+ ),
69
+ u(
70
+ "C\\u7ea7\\u5019\\u9009\\u9700\\u533a\\u5206\\u5f62\\u6001\\u8d70\\u574f\\u4e0e"
71
+ "\\u89c4\\u5219\\u672a\\u8986\\u76d6\\u7684\\u5f3a\\u52bf\\u6837\\u672c"
72
+ ),
73
+ ],
74
+ }
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+ from indiciumforge_core.labels.market_gate import REVIEW_COLUMNS
5
+ from indiciumforge_core.text import clean_text, u
6
+
7
+
8
+ def trigger_count_value(value: object) -> int:
9
+ text = str(value or "")
10
+ digits = ""
11
+ for char in text:
12
+ if char.isdigit():
13
+ digits += char
14
+ elif digits:
15
+ break
16
+ return int(digits) if digits else 0
17
+
18
+
19
+ def split_candidate_boards(value: str) -> list[str]:
20
+ out: list[str] = []
21
+ for raw in str(value or "").replace(",", ";").split(";"):
22
+ item = raw.strip()
23
+ if not item or item.startswith(("TDX:", "X:")):
24
+ continue
25
+ if item not in out:
26
+ out.append(item)
27
+ return out
28
+
29
+
30
+ def candidate_gate_boards(row: pd.Series) -> list[str]:
31
+ boards: list[str] = []
32
+ for column in (
33
+ REVIEW_COLUMNS["board_info"],
34
+ REVIEW_COLUMNS["strong_boards_hit"],
35
+ REVIEW_COLUMNS["weak_boards_hit"],
36
+ ):
37
+ boards.extend(split_candidate_boards(str(row.get(column) or "")))
38
+ return list(dict.fromkeys(boards))
39
+
40
+
41
+ def theme_state_lookup(
42
+ theme_state_ranking: pd.DataFrame,
43
+ *,
44
+ theme_name_key: str,
45
+ daily_keys: dict[str, str],
46
+ ) -> dict[str, dict[str, str]]:
47
+ if theme_state_ranking.empty or theme_name_key not in theme_state_ranking.columns:
48
+ return {}
49
+ lookup: dict[str, dict[str, str]] = {}
50
+ for _, row in theme_state_ranking.iterrows():
51
+ name = str(row.get(theme_name_key) or "").strip()
52
+ if not name:
53
+ continue
54
+ lookup[name] = {
55
+ "status": clean_text(row.get(daily_keys["status"])),
56
+ "daily_state": clean_text(row.get(daily_keys["daily_state"])),
57
+ "mid_state": clean_text(row.get(daily_keys["mid_state"])),
58
+ "risk_state": clean_text(row.get(daily_keys["risk_state"])),
59
+ "divergence_state": clean_text(row.get(daily_keys["divergence_state"])),
60
+ }
61
+ return lookup
62
+
63
+
64
+ def market_gate_workflow_rejects(row: pd.Series) -> list[str]:
65
+ rejects: list[str] = []
66
+ stock_name = clean_text(row.get(REVIEW_COLUMNS["stock_name"]))
67
+ if "ST" in stock_name.upper() or u("\\u9000") in stock_name:
68
+ rejects.append(u("\\u98ce\\u9669\\u8bc1\\u5238"))
69
+ if clean_text(row.get(REVIEW_COLUMNS["holding_period"])) == u("\\u4ec5\\u590d\\u76d8"):
70
+ rejects.append(u("\\u6301\\u6709\\u5468\\u671f\\u5206\\u5c42=\\u4ec5\\u590d\\u76d8"))
71
+ if clean_text(row.get(REVIEW_COLUMNS["narrative_stage"])) == u("\\u540e\\u6bb5\\u62e5\\u6324"):
72
+ rejects.append(u("\\u4e3b\\u7ebf\\u9636\\u6bb5=\\u540e\\u6bb5\\u62e5\\u6324"))
73
+ return rejects
74
+
75
+
76
+ def market_gate_workflow_risks(row: pd.Series) -> list[str]:
77
+ risks: list[str] = []
78
+ thesis_risk = clean_text(row.get(REVIEW_COLUMNS["thesis_risk"]))
79
+ if thesis_risk and thesis_risk != u("\\u6682\\u65e0"):
80
+ risks.append(f"{REVIEW_COLUMNS['thesis_risk']}={thesis_risk}")
81
+ trigger_count = clean_text(row.get(REVIEW_COLUMNS["trigger_count"]))
82
+ accounting_overview = clean_text(row.get(REVIEW_COLUMNS["accounting_risk_overview"]))
83
+ if trigger_count == u("\\u6570\\u636e\\u4e0d\\u8db3"):
84
+ risks.append(u("\\u4f1a\\u8ba1\\u6570\\u636e\\u4e0d\\u8db3"))
85
+ elif trigger_count_value(trigger_count) > 0:
86
+ risks.append(f"{REVIEW_COLUMNS['trigger_count']}={trigger_count}")
87
+ if accounting_overview and accounting_overview not in {
88
+ u("\\u672a\\u89c1\\u660e\\u663e\\u98ce\\u9669"),
89
+ u("\\u4e0d\\u9002\\u7528"),
90
+ }:
91
+ risks.append(f"{REVIEW_COLUMNS['accounting_risk_overview']}={accounting_overview}")
92
+ return risks
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+ from indiciumforge_core.labels.market_gate import (
5
+ GATE_RESULT_OBSERVATION,
6
+ GATE_RESULT_REJECTED,
7
+ GATE_RESULT_STRICT,
8
+ MARKET_DAILY,
9
+ MARKET_GATE_COLUMNS,
10
+ MARKET_ZH,
11
+ REVIEW_COLUMNS,
12
+ )
13
+ from indiciumforge_core.text import clean_text, join_unique, u
14
+
15
+ from indiciumforge_workflow.market_gate.active_watch import market_gate_active_watch
16
+ from indiciumforge_workflow.market_gate.helpers import (
17
+ candidate_gate_boards,
18
+ market_gate_workflow_rejects,
19
+ market_gate_workflow_risks,
20
+ theme_state_lookup,
21
+ )
22
+
23
+
24
+ def apply_market_gate(review: pd.DataFrame, theme_state_ranking: pd.DataFrame) -> pd.DataFrame:
25
+ if review.empty:
26
+ out = review.copy()
27
+ for column in MARKET_GATE_COLUMNS.values():
28
+ out[column] = ""
29
+ return out
30
+ theme_lookup = theme_state_lookup(
31
+ theme_state_ranking,
32
+ theme_name_key=MARKET_ZH["theme_name"],
33
+ daily_keys=MARKET_DAILY,
34
+ )
35
+ out = review.copy()
36
+ rows = [_market_gate_row(row, theme_lookup) for _, row in out.iterrows()]
37
+ for key, column in MARKET_GATE_COLUMNS.items():
38
+ out[column] = [item[key] for item in rows]
39
+ order = {
40
+ GATE_RESULT_STRICT: 0,
41
+ GATE_RESULT_OBSERVATION: 1,
42
+ GATE_RESULT_REJECTED: 2,
43
+ }
44
+ out["_market_gate_order"] = out[MARKET_GATE_COLUMNS["gate_result"]].map(order).fillna(9)
45
+ sort_columns = ["_market_gate_order"]
46
+ if REVIEW_COLUMNS["rating"] in out.columns:
47
+ sort_columns.append(REVIEW_COLUMNS["rating"])
48
+ return out.sort_values(sort_columns, ascending=True).drop(columns=["_market_gate_order"])
49
+
50
+
51
+ def _market_gate_row(row: pd.Series, themes: dict[str, dict[str, str]]) -> dict[str, str]:
52
+ boards = candidate_gate_boards(row)
53
+ matched = [(board, themes[board]) for board in boards if board in themes]
54
+ allowed_statuses = {
55
+ MARKET_DAILY["daily_strong"],
56
+ MARKET_DAILY["persistent"],
57
+ MARKET_DAILY["short_pulse"],
58
+ }
59
+ strong = [
60
+ board
61
+ for board, info in matched
62
+ if info["status"] in allowed_statuses
63
+ and info["risk_state"] != MARKET_DAILY["hard_weak"]
64
+ and info["divergence_state"] != MARKET_DAILY["divergent"]
65
+ ]
66
+ hard_weak = [
67
+ board
68
+ for board, info in matched
69
+ if info["risk_state"] == MARKET_DAILY["hard_weak"]
70
+ or info["status"] == MARKET_DAILY["hard_weak"]
71
+ ]
72
+ turning_weak = [
73
+ board
74
+ for board, info in matched
75
+ if info["status"] == MARKET_DAILY["mid_strong_daily_weak"]
76
+ ]
77
+ divergent = [
78
+ board
79
+ for board, info in matched
80
+ if info["divergence_state"] == MARKET_DAILY["divergent"]
81
+ or info["status"] == MARKET_DAILY["divergent"]
82
+ ]
83
+ workflow_rejects = market_gate_workflow_rejects(row)
84
+ workflow_risks = market_gate_workflow_risks(row)
85
+ reasons: list[str] = []
86
+ observe_reasons: list[str] = []
87
+ if not matched:
88
+ reasons.append(u("\\u672a\\u547d\\u4e2d\\u76d8\\u9762\\u65e5\\u62a5\\u65b9\\u5411"))
89
+ if hard_weak:
90
+ reasons.append(f"{MARKET_DAILY['hard_weak']}={join_unique(hard_weak)}")
91
+ if workflow_rejects:
92
+ reasons.extend(workflow_rejects)
93
+ if turning_weak:
94
+ observe_reasons.append(
95
+ f"{MARKET_DAILY['mid_strong_daily_weak']}={join_unique(turning_weak)}"
96
+ )
97
+ if divergent:
98
+ observe_reasons.append(f"{MARKET_DAILY['divergent']}={join_unique(divergent)}")
99
+ if workflow_risks:
100
+ observe_reasons.extend(workflow_risks)
101
+ if reasons:
102
+ gate_result = GATE_RESULT_REJECTED
103
+ elif strong and not observe_reasons:
104
+ gate_result = GATE_RESULT_STRICT
105
+ elif strong or observe_reasons:
106
+ gate_result = GATE_RESULT_OBSERVATION
107
+ else:
108
+ gate_result = GATE_RESULT_REJECTED
109
+ reasons.append(u("\\u672a\\u547d\\u4e2d\\u53ef\\u4e25\\u683c\\u901a\\u8fc7\\u65b9\\u5411"))
110
+ active = market_gate_active_watch(row, gate_result, strong, observe_reasons, reasons)
111
+ return {
112
+ "gate_result": gate_result,
113
+ "active_watch_level": active["level"],
114
+ "active_watch_reason": active["reason"],
115
+ "active_watch_action": active["action"],
116
+ "matched_themes": join_unique([board for board, _ in matched]),
117
+ "theme_status": join_unique(
118
+ [f"{board}={info['status']}" for board, info in matched if info["status"]]
119
+ ),
120
+ "daily_state": join_unique(
121
+ [f"{board}={info['daily_state']}" for board, info in matched if info["daily_state"]]
122
+ ),
123
+ "mid_state": join_unique(
124
+ [f"{board}={info['mid_state']}" for board, info in matched if info["mid_state"]]
125
+ ),
126
+ "risk_state": join_unique(
127
+ [f"{board}={info['risk_state']}" for board, info in matched if info["risk_state"]]
128
+ ),
129
+ "divergence_state": join_unique(
130
+ [
131
+ f"{board}={info['divergence_state']}"
132
+ for board, info in matched
133
+ if info["divergence_state"]
134
+ ]
135
+ ),
136
+ "reject_reason": join_unique(reasons),
137
+ "observe_reason": join_unique(observe_reasons),
138
+ "workflow_layer": clean_text(row.get(REVIEW_COLUMNS["holding_period"])),
139
+ }
140
+
141
+
142
+ def split_market_gate_frames(
143
+ gated: pd.DataFrame,
144
+ ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame]:
145
+ strict = gated[gated[MARKET_GATE_COLUMNS["gate_result"]].eq(GATE_RESULT_STRICT)].copy()
146
+ observation = gated[
147
+ gated[MARKET_GATE_COLUMNS["gate_result"]].eq(GATE_RESULT_OBSERVATION)
148
+ ].copy()
149
+ rejected = gated[gated[MARKET_GATE_COLUMNS["gate_result"]].eq(GATE_RESULT_REJECTED)].copy()
150
+ active_watch = market_gate_active_watch_frame(gated)
151
+ return strict, observation, rejected, active_watch
152
+
153
+
154
+ def market_gate_active_watch_frame(gated: pd.DataFrame) -> pd.DataFrame:
155
+ if gated.empty or MARKET_GATE_COLUMNS["active_watch_level"] not in gated.columns:
156
+ return gated.head(0).copy()
157
+ active = gated[
158
+ gated[MARKET_GATE_COLUMNS["active_watch_level"]].astype(str).str.strip().ne("")
159
+ & ~gated[MARKET_GATE_COLUMNS["gate_result"]].eq(GATE_RESULT_STRICT)
160
+ ].copy()
161
+ order = {
162
+ u("\\u53ef\\u4e3b\\u52a8\\u590d\\u6838"): 0,
163
+ u("\\u9057\\u6f0f\\u5ba1\\u8ba1"): 1,
164
+ u("\\u9ad8\\u6f6e\\u4e0d\\u8ffd"): 2,
165
+ u("\\u4ec5\\u89c2\\u5bdf"): 3,
166
+ }
167
+ active["_active_watch_order"] = (
168
+ active[MARKET_GATE_COLUMNS["active_watch_level"]].map(order).fillna(9)
169
+ )
170
+ sort_columns = ["_active_watch_order"]
171
+ if REVIEW_COLUMNS["rating"] in active.columns:
172
+ sort_columns.append(REVIEW_COLUMNS["rating"])
173
+ return active.sort_values(sort_columns).drop(columns=["_active_watch_order"])
@@ -0,0 +1,110 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+ from typing import Any
5
+
6
+ import pandas as pd
7
+ from indiciumforge_core.labels.market_gate import MARKET_GATE_COLUMNS, REVIEW_COLUMNS, WORKFLOW_ZH
8
+
9
+
10
+ def render_market_gate_markdown(frame: pd.DataFrame, trade_date: date, label: str) -> str:
11
+ lines = [
12
+ f"# {trade_date.isoformat()} workflow market gate - {label}",
13
+ "",
14
+ WORKFLOW_ZH["disclaimer"],
15
+ "",
16
+ f"- {MARKET_GATE_COLUMNS['gate_result']}: {label}",
17
+ f"- rows: {len(frame)}",
18
+ "",
19
+ ]
20
+ if not frame.empty:
21
+ display_columns = [
22
+ REVIEW_COLUMNS["code"],
23
+ REVIEW_COLUMNS["stock_name"],
24
+ REVIEW_COLUMNS["rating"],
25
+ MARKET_GATE_COLUMNS["gate_result"],
26
+ MARKET_GATE_COLUMNS["matched_themes"],
27
+ MARKET_GATE_COLUMNS["theme_status"],
28
+ MARKET_GATE_COLUMNS["reject_reason"],
29
+ MARKET_GATE_COLUMNS["observe_reason"],
30
+ REVIEW_COLUMNS["holding_period"],
31
+ REVIEW_COLUMNS["narrative_stage"],
32
+ REVIEW_COLUMNS["thesis_risk"],
33
+ ]
34
+ columns = [column for column in display_columns if column in frame.columns]
35
+ lines.append(frame[columns].head(120).to_markdown(index=False))
36
+ return "\n".join(lines)
37
+
38
+
39
+ def render_market_gate_active_watch_markdown(frame: pd.DataFrame, trade_date: date) -> str:
40
+ lines = [
41
+ f"# {trade_date.isoformat()} workflow market gate - active watch",
42
+ "",
43
+ WORKFLOW_ZH["disclaimer"],
44
+ "",
45
+ "- boundary: active watch is not strict execution pass",
46
+ f"- rows: {len(frame)}",
47
+ "",
48
+ ]
49
+ if not frame.empty:
50
+ display_columns = [
51
+ REVIEW_COLUMNS["code"],
52
+ REVIEW_COLUMNS["stock_name"],
53
+ REVIEW_COLUMNS["rating"],
54
+ MARKET_GATE_COLUMNS["gate_result"],
55
+ MARKET_GATE_COLUMNS["active_watch_level"],
56
+ MARKET_GATE_COLUMNS["active_watch_reason"],
57
+ MARKET_GATE_COLUMNS["active_watch_action"],
58
+ MARKET_GATE_COLUMNS["matched_themes"],
59
+ MARKET_GATE_COLUMNS["reject_reason"],
60
+ MARKET_GATE_COLUMNS["observe_reason"],
61
+ ]
62
+ columns = [column for column in display_columns if column in frame.columns]
63
+ lines.append(frame[columns].head(120).to_markdown(index=False))
64
+ return "\n".join(lines)
65
+
66
+
67
+ def render_market_gate_summary_markdown(summary: dict[str, Any]) -> str:
68
+ lines = [
69
+ f"# workflow market gate summary - {summary.get('trade_date', '')}",
70
+ "",
71
+ WORKFLOW_ZH["disclaimer"],
72
+ "",
73
+ f"- rule_version: {summary.get('rule_version', '')}",
74
+ f"- candidate_count: {summary.get('candidate_count', 0)}",
75
+ f"- strict_count: {summary.get('strict_count', 0)}",
76
+ f"- observation_count: {summary.get('observation_count', 0)}",
77
+ f"- watch_count: {summary.get('watch_count', 0)}",
78
+ f"- rejected_count: {summary.get('rejected_count', 0)}",
79
+ f"- quality_gate_warning: {summary.get('quality_gate_warning', '')}",
80
+ f"- theme_state_ranking: {summary.get('theme_state_ranking', '')}",
81
+ f"- workflow_review: {summary.get('workflow_review', '')}",
82
+ ]
83
+ warnings = summary.get("warnings") or []
84
+ if warnings:
85
+ lines.extend(["", "## Warnings", ""])
86
+ lines.extend(f"- {item}" for item in warnings)
87
+ return "\n".join(lines)
88
+
89
+
90
+ def render_market_gate_calibration_markdown(audit: dict[str, Any]) -> str:
91
+ lines = [
92
+ f"# workflow market gate calibration audit - {audit.get('trade_date', '')}",
93
+ "",
94
+ WORKFLOW_ZH["disclaimer"],
95
+ "",
96
+ "- strict execution gate stays strict; this audit is for rule calibration.",
97
+ f"- candidate_count: {audit.get('candidate_count', 0)}",
98
+ f"- strict_count: {audit.get('strict_count', 0)}",
99
+ f"- observation_count: {audit.get('observation_count', 0)}",
100
+ f"- watch_count: {audit.get('watch_count', 0)}",
101
+ f"- rejected_count: {audit.get('rejected_count', 0)}",
102
+ f"- quality_gate_warning: {audit.get('quality_gate_warning', '')}",
103
+ f"- outcome_status: {audit.get('outcome_status', '')}",
104
+ "",
105
+ ]
106
+ top_missed = audit.get("top_missed_candidates") or []
107
+ if top_missed:
108
+ table = pd.DataFrame(top_missed).to_markdown(index=False)
109
+ lines.extend(["## top_missed_candidates", "", table])
110
+ return "\n".join(lines)
@@ -0,0 +1,51 @@
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 workflow_root
8
+ from indiciumforge_core.text import u
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ReviewResolution:
13
+ path: Path
14
+ source_stage: str
15
+ warning: str
16
+
17
+
18
+ def resolve_market_gate_review_path(artifact_root: Path, trade_date: date) -> ReviewResolution:
19
+ root = workflow_root(artifact_root, trade_date)
20
+ candidates = [
21
+ ("preopen", root / "preopen" / "buy_point_review_internal.csv", ""),
22
+ (
23
+ "preopen",
24
+ root / "preopen" / "buy_point_review.csv",
25
+ u(
26
+ "\\u672a\\u627e\\u5230preopen internal\\u5019\\u9009\\u8868\\uff0c"
27
+ "\\u5df2\\u56de\\u9000\\u8bfb\\u53d6preopen\\u516c\\u5f00\\u8868"
28
+ ),
29
+ ),
30
+ (
31
+ "post_close",
32
+ root / "post_close" / "buy_point_review_internal.csv",
33
+ u(
34
+ "\\u672a\\u627e\\u5230preopen\\u5019\\u9009\\u8868\\uff0c"
35
+ "\\u5df2\\u56de\\u9000\\u8bfb\\u53d6post_close internal\\u5019\\u9009\\u8868"
36
+ ),
37
+ ),
38
+ (
39
+ "post_close",
40
+ root / "post_close" / "buy_point_review.csv",
41
+ u(
42
+ "\\u672a\\u627e\\u5230preopen\\u5019\\u9009\\u8868\\uff0c"
43
+ "\\u5df2\\u56de\\u9000\\u8bfb\\u53d6post_close\\u516c\\u5f00\\u8868"
44
+ ),
45
+ ),
46
+ ]
47
+ for stage, path, warning in candidates:
48
+ if path.exists():
49
+ return ReviewResolution(path=path, source_stage=stage, warning=warning)
50
+ searched = ", ".join(str(path) for _, path, _ in candidates)
51
+ raise FileNotFoundError(f"missing workflow review for market-gate: {searched}")