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.
- indiciumforge_workflow/__init__.py +1 -0
- indiciumforge_workflow/e2e/__init__.py +1 -0
- indiciumforge_workflow/e2e/synthetic.py +155 -0
- indiciumforge_workflow/factor_scan/__init__.py +11 -0
- indiciumforge_workflow/factor_scan/runner.py +77 -0
- indiciumforge_workflow/market_awareness/__init__.py +35 -0
- indiciumforge_workflow/market_awareness/artifacts.py +70 -0
- indiciumforge_workflow/market_awareness/classifier.py +121 -0
- indiciumforge_workflow/market_awareness/fixtures.py +57 -0
- indiciumforge_workflow/market_awareness/models.py +39 -0
- indiciumforge_workflow/market_awareness/runner.py +51 -0
- indiciumforge_workflow/market_gate/__init__.py +1 -0
- indiciumforge_workflow/market_gate/active_watch.py +85 -0
- indiciumforge_workflow/market_gate/calibration.py +74 -0
- indiciumforge_workflow/market_gate/helpers.py +92 -0
- indiciumforge_workflow/market_gate/kernel.py +173 -0
- indiciumforge_workflow/market_gate/render.py +110 -0
- indiciumforge_workflow/market_gate/resolver.py +51 -0
- indiciumforge_workflow/market_gate/runner.py +157 -0
- indiciumforge_workflow/parity/__init__.py +1 -0
- indiciumforge_workflow/parity/runner.py +54 -0
- indiciumforge_workflow/workflow_chain/__init__.py +1 -0
- indiciumforge_workflow/workflow_chain/runner.py +533 -0
- indiciumforge_workflow/workflow_chain/skeleton.py +76 -0
- indiciumforge_workflow-2.0.0.dist-info/METADATA +44 -0
- indiciumforge_workflow-2.0.0.dist-info/RECORD +28 -0
- indiciumforge_workflow-2.0.0.dist-info/WHEEL +5 -0
- 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}")
|