ranranrunforit commited on
Commit
79cd13b
·
verified ·
1 Parent(s): c517f86

Upload 13 files

Browse files
Files changed (13) hide show
  1. app.py +267 -0
  2. automation.py +117 -0
  3. chan_engine.py +1451 -0
  4. chan_enhance.py +123 -0
  5. chan_glue.py +69 -0
  6. chan_multilevel.py +884 -0
  7. data_us.py +115 -0
  8. llm_local.py +107 -0
  9. news_watch.py +123 -0
  10. requirements.txt +8 -0
  11. research.py +121 -0
  12. rotation.py +155 -0
  13. signal_runner.py +124 -0
app.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Chan Compass — US edition
3
+ Multi-timeframe Chan-theory signals · sector rotation · local-LLM research.
4
+
5
+ UI follows Adobe Spectrum 2 design language (https://s2.spectrum.adobe.com /
6
+ github.com/adobe/react-spectrum), approximated in Gradio CSS:
7
+ * Adobe Clean is proprietary → Source Sans 3 (Adobe's open font) instead
8
+ * Spectrum 2 accent blue #0265DC, gray-50 canvas, white cards
9
+ * signature fully-rounded "pill" buttons, soft 16px card radii, focus rings
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import gradio as gr
14
+ import pandas as pd
15
+
16
+ import automation
17
+ import llm_local
18
+ import news_watch
19
+ import research
20
+ import rotation
21
+ import signal_runner
22
+
23
+ # ─────────────────────────────────────────── Spectrum 2 theme ──
24
+ S2_FONT = gr.themes.GoogleFont("Source Sans 3")
25
+ S2_MONO = gr.themes.GoogleFont("Source Code Pro")
26
+
27
+ theme = gr.themes.Default(
28
+ font=[S2_FONT, "Adobe Clean", "system-ui", "sans-serif"],
29
+ font_mono=[S2_MONO, "monospace"],
30
+ primary_hue=gr.themes.colors.blue,
31
+ neutral_hue=gr.themes.colors.gray,
32
+ radius_size=gr.themes.sizes.radius_lg,
33
+ ).set(
34
+ button_primary_background_fill="#0265DC",
35
+ button_primary_background_fill_hover="#0054B6",
36
+ button_primary_text_color="#FFFFFF",
37
+ button_secondary_background_fill="#FFFFFF",
38
+ button_secondary_border_color="#B1B1B1",
39
+ button_secondary_text_color="#222222",
40
+ block_background_fill="#FFFFFF",
41
+ background_fill_primary="#F8F8F8",
42
+ background_fill_secondary="#FFFFFF",
43
+ border_color_primary="#E6E6E6",
44
+ block_shadow="0 1px 4px rgba(0,0,0,.06)",
45
+ block_radius="16px",
46
+ input_radius="8px",
47
+ )
48
+
49
+ S2_CSS = """
50
+ :root{
51
+ --s2-accent:#0265DC; --s2-accent-down:#0054B6;
52
+ --s2-gray-50:#F8F8F8; --s2-gray-75:#F3F3F3; --s2-gray-200:#E6E6E6;
53
+ --s2-gray-800:#292929; --s2-positive:#007A39; --s2-negative:#D7373F;
54
+ }
55
+ body, .gradio-container{background:var(--s2-gray-50)!important;color:var(--s2-gray-800);}
56
+ .gradio-container{max-width:1280px!important;margin:0 auto!important;}
57
+
58
+ /* Spectrum 2 signature: pill buttons */
59
+ button{border-radius:999px!important;font-weight:600!important;}
60
+ button:focus-visible{outline:2px solid var(--s2-accent)!important;outline-offset:2px;}
61
+
62
+ /* hero */
63
+ #s2-hero{background:linear-gradient(120deg,#0265DC 0%,#5258E4 55%,#7326D3 100%);
64
+ border-radius:20px;padding:28px 32px;color:#fff;margin-bottom:6px;}
65
+ #s2-hero h1{margin:0;font-size:30px;font-weight:800;letter-spacing:-.5px;color:#fff;}
66
+ #s2-hero p{margin:6px 0 0;opacity:.92;font-size:15px;color:#fff;}
67
+ #s2-hero .chips span{display:inline-block;background:rgba(255,255,255,.16);
68
+ border:1px solid rgba(255,255,255,.35);border-radius:999px;padding:3px 12px;
69
+ font-size:12.5px;margin:10px 8px 0 0;}
70
+
71
+ /* tab strip — Spectrum quiet tabs with accent underline */
72
+ .tab-nav{border-bottom:2px solid var(--s2-gray-200)!important;}
73
+ .tab-nav button{border-radius:8px 8px 0 0!important;font-size:15px!important;
74
+ color:#6e6e6e!important;background:transparent!important;border:none!important;}
75
+ .tab-nav button.selected{color:var(--s2-accent)!important;
76
+ box-shadow:inset 0 -3px 0 var(--s2-accent)!important;font-weight:700!important;}
77
+
78
+ /* cards */
79
+ .block{border:1px solid var(--s2-gray-200)!important;}
80
+ table{font-size:13.5px!important;}
81
+ thead th{background:var(--s2-gray-75)!important;font-weight:700!important;}
82
+
83
+ .s2-footnote{color:#6e6e6e;font-size:12.5px;}
84
+ #detail-log textarea{font-family:'Source Code Pro',monospace!important;font-size:12.5px!important;}
85
+ """
86
+
87
+ # ─────────────────────────────────────────── UI callbacks ──
88
+ def ui_run_signals(tickers_text, force):
89
+ tickers = [t for t in tickers_text.replace("\n", ",").split(",") if t.strip()]
90
+ msg = automation.run_pipeline(tickers or None, force=bool(force))
91
+ df = automation.STATE["signals_df"]
92
+ choices = sorted(automation.STATE["signals_details"].keys())
93
+ d1, d5, d20, asof = automation.STATE["rotation"]
94
+ return (
95
+ df if df is not None else pd.DataFrame(),
96
+ automation.STATE["signals_summary"] + f" · {msg}",
97
+ gr.update(choices=choices, value=(choices[0] if choices else None)),
98
+ rotation.fmt_table(d1), rotation.fmt_table(d5), rotation.fmt_table(d20),
99
+ f"Sector flows as of **{asof}**",
100
+ automation.STATE["rotation_narrative"],
101
+ automation.STATE["news_md"],
102
+ )
103
+
104
+
105
+ def ui_show_detail(ticker):
106
+ if not ticker:
107
+ return "Select a ticker after running the pipeline."
108
+ return automation.STATE["signals_details"].get(ticker, "No detail for this ticker yet.")
109
+
110
+
111
+ def ui_explain_detail(ticker):
112
+ txt = automation.STATE["signals_details"].get(ticker or "", "")
113
+ if not txt:
114
+ return "Run the pipeline and select a ticker first."
115
+ if not llm_local.is_loaded():
116
+ return "Load a model in the **Model** tab first."
117
+ prompt = ("Below is a Chan-theory (缠论) multi-timeframe decision log in Chinese "
118
+ "for a US stock. Explain it in plain English for a trader: the final "
119
+ "action, why each timeframe gate passed/failed, and what would "
120
+ "invalidate the call. ≤200 words.\n\n" + txt[:6000])
121
+ return llm_local.chat(prompt, max_tokens=520)
122
+
123
+
124
+ def ui_refresh_rotation():
125
+ d1, d5, d20, asof = rotation.build_rotation(force=True)
126
+ automation.STATE["rotation"] = (d1, d5, d20, asof)
127
+ narrative = rotation.llm_narrative(d1, d5, d20)
128
+ automation.STATE["rotation_narrative"] = narrative
129
+ return (rotation.fmt_table(d1), rotation.fmt_table(d5), rotation.fmt_table(d20),
130
+ f"Sector flows as of **{asof}**", narrative)
131
+
132
+
133
+ def ui_save_holdings(text):
134
+ saved = news_watch.save_holdings(text.replace("\n", ",").split(","))
135
+ return f"Saved {len(saved)} holding(s): {', '.join(saved) if saved else '—'}"
136
+
137
+
138
+ def ui_check_news():
139
+ md = news_watch.check_holdings_news()
140
+ automation.STATE["news_md"] = md
141
+ return md
142
+
143
+
144
+ def ui_research(ticker):
145
+ return research.research_note(ticker)
146
+
147
+
148
+ def ui_load_model(name):
149
+ return llm_local.load_model(name)
150
+
151
+
152
+ def ui_automation_panel():
153
+ return automation.schedule_info(), "\n".join(automation.STATE["log"][-30:]) or "(no log yet)"
154
+
155
+
156
+ # ─────────────────────────────────────────── layout ──
157
+ _GR_MAJOR = int(gr.__version__.split(".")[0])
158
+ _style_kw = {} if _GR_MAJOR >= 6 else {"theme": theme, "css": S2_CSS}
159
+
160
+ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
161
+ gr.HTML("""
162
+ <div id="s2-hero">
163
+ <h1>Chan Compass <span style="font-weight:300">· US Markets</span></h1>
164
+ <p>Multi-timeframe 缠论 (Chan theory) signal engine — monthly → weekly → daily → 60m → 30m → 15m → 5m
165
+ nested-interval confirmation — plus sector capital rotation and a fully local llama.cpp research brain.</p>
166
+ <div class="chips">
167
+ <span>🧠 Local GGUF · no cloud APIs</span><span>🦙 llama.cpp runtime</span>
168
+ <span>📊 Yahoo Finance data</span><span>⏰ Auto-update 18:10 ET</span>
169
+ <span>🎨 Spectrum 2 design</span>
170
+ </div>
171
+ </div>""")
172
+
173
+ with gr.Tab("📈 Signals"):
174
+ with gr.Row():
175
+ tickers_in = gr.Textbox(value=", ".join(signal_runner.DEFAULT_POOL),
176
+ label="Ticker pool (comma separated)", scale=4)
177
+ force_cb = gr.Checkbox(value=True, label="Force fresh download", scale=1)
178
+ run_btn = gr.Button("▶ Run analysis", variant="primary", scale=1)
179
+ sig_summary = gr.Markdown(automation.STATE["signals_summary"])
180
+ sig_table = gr.Dataframe(label="Next-session plan (sorted: BUY → SELL → HOLD → WATCH)",
181
+ interactive=False, wrap=True)
182
+ gr.Markdown("**Decision log** — the engine's full multi-timeframe ruling chain "
183
+ "(engine output is in Chinese; use the button for an English explanation).",
184
+ elem_classes=["s2-footnote"])
185
+ with gr.Row():
186
+ detail_pick = gr.Dropdown(choices=[], label="Ticker", scale=2)
187
+ explain_btn = gr.Button("🌐 Explain in English (local LLM)", scale=1)
188
+ detail_box = gr.Textbox(lines=18, label="Ruling chain", elem_id="detail-log")
189
+ explain_box = gr.Markdown()
190
+
191
+ with gr.Tab("🔄 Sector Rotation"):
192
+ gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500 coverage). "
193
+ "**Flow proxy = price change % × dollar volume**; RS = return minus SPY. "
194
+ "True per-sector fund-flow feeds are paid data — this is the standard free proxy.")
195
+ rot_btn = gr.Button("↻ Refresh rotation", variant="primary")
196
+ rot_asof = gr.Markdown("Press refresh (or Run analysis on the Signals tab).")
197
+ with gr.Row():
198
+ rot_1d = gr.Dataframe(label="1-Day (today's rotation)", interactive=False)
199
+ with gr.Row():
200
+ rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
201
+ rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
202
+ rot_ai = gr.Markdown(label="AI rotation narrative")
203
+
204
+ with gr.Tab("📰 Watchlist News"):
205
+ gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
206
+ "News found → AI brief is pushed below. No news → the ticker is ignored "
207
+ "(listed under *Quiet today*).")
208
+ with gr.Row():
209
+ hold_in = gr.Textbox(value=", ".join(news_watch.load_holdings()),
210
+ label="My holdings (comma separated)", scale=3)
211
+ save_btn = gr.Button("💾 Save holdings", scale=1)
212
+ news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
213
+ hold_status = gr.Markdown()
214
+ news_out = gr.Markdown()
215
+
216
+ with gr.Tab("🧪 Research (beta)"):
217
+ gr.Markdown("**Research Note (beta)** — first slice of Auto-Research: fundamentals + "
218
+ "headlines from Yahoo → the local model writes a structured note: "
219
+ "valuation · moat & supply-chain position · bull/bear case · risks. "
220
+ "_V2 (planned): multi-step agent research and auto-report on every new ticker._")
221
+ with gr.Row():
222
+ res_in = gr.Textbox(label="Ticker", placeholder="e.g. NVDA", scale=3)
223
+ res_btn = gr.Button("📝 Generate research note", variant="primary", scale=1)
224
+ res_out = gr.Markdown()
225
+
226
+ with gr.Tab("⏰ Automation"):
227
+ auto_md = gr.Markdown(automation.schedule_info())
228
+ with gr.Row():
229
+ auto_now = gr.Button("⚡ Run now", variant="primary")
230
+ auto_refresh = gr.Button("↻ Refresh status")
231
+ auto_msg = gr.Markdown()
232
+ auto_log = gr.Textbox(lines=12, label="Pipeline log", elem_id="detail-log")
233
+
234
+ with gr.Tab("🧠 Model"):
235
+ gr.Markdown("All AI runs **locally** through **llama.cpp** (llama-cpp-python) with "
236
+ "Qwen3 GGUF weights — every option is far below the 32B-parameter cap, "
237
+ "and nothing leaves the machine. First load downloads the GGUF once.")
238
+ model_pick = gr.Radio(choices=list(llm_local.MODEL_ZOO.keys()),
239
+ value=llm_local.DEFAULT_MODEL, label="Model")
240
+ load_btn = gr.Button("⬇ Load model", variant="primary")
241
+ model_status = gr.Markdown(llm_local.status())
242
+
243
+ gr.Markdown("Chan Compass · educational tool, not investment advice · "
244
+ "data: Yahoo Finance · design language: Adobe Spectrum 2",
245
+ elem_classes=["s2-footnote"])
246
+
247
+ # wiring
248
+ run_btn.click(ui_run_signals, [tickers_in, force_cb],
249
+ [sig_table, sig_summary, detail_pick,
250
+ rot_1d, rot_5d, rot_20d, rot_asof, rot_ai, news_out])
251
+ detail_pick.change(ui_show_detail, detail_pick, detail_box)
252
+ explain_btn.click(ui_explain_detail, detail_pick, explain_box)
253
+ rot_btn.click(ui_refresh_rotation, None, [rot_1d, rot_5d, rot_20d, rot_asof, rot_ai])
254
+ save_btn.click(ui_save_holdings, hold_in, hold_status)
255
+ news_btn.click(ui_check_news, None, news_out)
256
+ res_btn.click(ui_research, res_in, res_out)
257
+ auto_now.click(lambda: automation.run_pipeline(force=True), None, auto_msg)
258
+ auto_refresh.click(ui_automation_panel, None, [auto_md, auto_log])
259
+ load_btn.click(ui_load_model, model_pick, model_status)
260
+
261
+ automation.start_scheduler()
262
+
263
+ if __name__ == "__main__":
264
+ if _GR_MAJOR >= 6:
265
+ demo.launch(theme=theme, css=S2_CSS)
266
+ else:
267
+ demo.launch()
automation.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ automation.py — daily auto-update pipeline.
3
+
4
+ Chosen update time (requirement #2): **18:10 America/New_York**, Mon–Fri.
5
+ Why: NYSE/Nasdaq close at 16:00 ET; the closing auction, after-hours prints
6
+ and Yahoo's consolidated daily bar settle over the following ~1–2 hours.
7
+ By 18:10 ET the official daily OHLCV is stable, so we always capture the
8
+ finished trading day exactly once (= 07:10 next morning Beijing time).
9
+
10
+ Pipeline per run:
11
+ 1. refresh data for the signal pool + holdings + sector ETFs (yfinance)
12
+ 2. re-run multi-level Chan signals → cached for the Signals tab
13
+ 3. rebuild the sector rotation tables
14
+ 4. check today's news for every holding (push brief / ignore if quiet)
15
+
16
+ NOTE for Hugging Face free Spaces: free hardware sleeps after ~48h without
17
+ traffic, and a sleeping Space cannot fire its scheduler. Everything here also
18
+ runs on demand via the "Run now" button; on paid "always-on" hardware the
19
+ schedule fires unattended.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import datetime as dt
24
+ import threading
25
+ import traceback
26
+ from zoneinfo import ZoneInfo
27
+
28
+ NY = ZoneInfo("America/New_York")
29
+ RUN_HOUR, RUN_MINUTE = 18, 10
30
+
31
+ STATE = {
32
+ "signals_df": None,
33
+ "signals_details": {},
34
+ "signals_summary": "Not run yet — press “Run now” or wait for the 18:10 ET schedule.",
35
+ "rotation": (None, None, None, "—"),
36
+ "rotation_narrative": "",
37
+ "news_md": "",
38
+ "last_run": None,
39
+ "running": False,
40
+ "log": [],
41
+ }
42
+ _lock = threading.Lock()
43
+
44
+
45
+ def _log(msg: str):
46
+ stamp = dt.datetime.now(NY).strftime("%m-%d %H:%M:%S ET")
47
+ STATE["log"] = (STATE["log"] + [f"[{stamp}] {msg}"])[-60:]
48
+
49
+
50
+ def run_pipeline(tickers=None, force: bool = True) -> str:
51
+ """Full daily refresh. Safe to call from the UI or the scheduler."""
52
+ import news_watch
53
+ import rotation
54
+ import signal_runner
55
+
56
+ with _lock:
57
+ if STATE["running"]:
58
+ return "A pipeline run is already in progress."
59
+ STATE["running"] = True
60
+ try:
61
+ _log("Pipeline start: refreshing data + signals…")
62
+ df, details, summary, errors = signal_runner.run_signals(tickers, force=force)
63
+ STATE["signals_df"] = df
64
+ STATE["signals_details"] = details
65
+ STATE["signals_summary"] = summary
66
+ for e in errors:
67
+ _log(f"signal skip: {e}")
68
+ _log(f"Signals done: {summary}")
69
+
70
+ d1, d5, d20, asof = rotation.build_rotation(force=force)
71
+ STATE["rotation"] = (d1, d5, d20, asof)
72
+ try:
73
+ STATE["rotation_narrative"] = rotation.llm_narrative(d1, d5, d20)
74
+ except Exception as e:
75
+ STATE["rotation_narrative"] = f"(narrative failed: {e})"
76
+ _log(f"Sector rotation rebuilt (as of {asof}).")
77
+
78
+ STATE["news_md"] = news_watch.check_holdings_news()
79
+ _log("Holdings news checked.")
80
+
81
+ STATE["last_run"] = dt.datetime.now(NY)
82
+ _log("Pipeline finished.")
83
+ return f"Done. {summary}"
84
+ except Exception as e:
85
+ traceback.print_exc()
86
+ _log(f"Pipeline error: {e}")
87
+ return f"Pipeline error: {e}"
88
+ finally:
89
+ STATE["running"] = False
90
+
91
+
92
+ def start_scheduler():
93
+ """Cron: Mon–Fri 18:10 America/New_York."""
94
+ try:
95
+ from apscheduler.schedulers.background import BackgroundScheduler
96
+ from apscheduler.triggers.cron import CronTrigger
97
+ except Exception as e:
98
+ _log(f"APScheduler unavailable: {e}")
99
+ return None
100
+ sched = BackgroundScheduler(timezone=NY)
101
+ sched.add_job(run_pipeline, CronTrigger(day_of_week="mon-fri",
102
+ hour=RUN_HOUR, minute=RUN_MINUTE),
103
+ id="daily_pipeline", max_instances=1, coalesce=True)
104
+ sched.start()
105
+ _log(f"Scheduler armed: Mon–Fri {RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York.")
106
+ return sched
107
+
108
+
109
+ def schedule_info() -> str:
110
+ now = dt.datetime.now(NY)
111
+ last = STATE["last_run"].strftime("%Y-%m-%d %H:%M ET") if STATE["last_run"] else "never"
112
+ return (f"**Schedule:** Mon–Fri **{RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York** "
113
+ f"(market closes 16:00 ET; by 18:10 the official daily bar has settled — "
114
+ f"that's 07:10 next morning Beijing time).\n\n"
115
+ f"**Now (ET):** {now.strftime('%Y-%m-%d %H:%M')} · **Last run:** {last}\n\n"
116
+ f"⚠️ On free Space hardware the app sleeps when idle and the timer can't fire; "
117
+ f"use **Run now**, or upgrade to always-on hardware for unattended updates.")
chan_engine.py ADDED
@@ -0,0 +1,1451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 缠论引擎 v2.1 (原始版, 用于P0-P3改造的基线)
3
+ """
4
+ from __future__ import annotations
5
+ from dataclasses import dataclass, field
6
+ from typing import Optional
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ PIVOT_MAX_EXTEND_SEGS = 6
11
+
12
+
13
+ @dataclass
14
+ class Fractal:
15
+ idx: int
16
+ date: pd.Timestamp
17
+ kind: str
18
+ price: float
19
+ k_high: float
20
+ k_low: float
21
+
22
+
23
+ @dataclass
24
+ class Bi:
25
+ start: Fractal
26
+ end: Fractal
27
+ direction: str
28
+ bars: int
29
+ high: float
30
+ low: float
31
+ @property
32
+ def amplitude(self) -> float:
33
+ return self.high - self.low
34
+
35
+
36
+ @dataclass
37
+ class Seg:
38
+ start: Fractal
39
+ end: Fractal
40
+ direction: str
41
+ bis: list
42
+ high: float
43
+ low: float
44
+ confirmed: bool = True
45
+
46
+
47
+ @dataclass
48
+ class Pivot:
49
+ start_date: pd.Timestamp
50
+ end_date: pd.Timestamp
51
+ zg: float
52
+ zd: float
53
+ gg: float
54
+ dd: float
55
+ bis: list
56
+ direction: str
57
+ zg_date: Optional[pd.Timestamp] = None
58
+ zd_date: Optional[pd.Timestamp] = None
59
+ gg_date: Optional[pd.Timestamp] = None
60
+ dd_date: Optional[pd.Timestamp] = None
61
+ g: Optional[float] = None
62
+ d: Optional[float] = None
63
+ state: str = 'new'
64
+ death_combo: str = ''
65
+ capped: bool = False
66
+ upgraded_level: str = ''
67
+
68
+
69
+ @dataclass
70
+ class DivergenceGrade:
71
+ grade: str
72
+ area_ok: bool
73
+ dif_ok: bool
74
+ area_ratio: float
75
+ a_area: float
76
+ c_area: float
77
+ a_dif: float
78
+ c_dif: float
79
+ direction: str
80
+ reason: str
81
+ is_trend_divergence: bool = False
82
+ n_trend_pivots: int = 0
83
+
84
+
85
+ @dataclass
86
+ class Signal:
87
+ kind: str
88
+ date: pd.Timestamp
89
+ price: float
90
+ reason: str
91
+ pivot_zg: Optional[float] = None
92
+ pivot_zd: Optional[float] = None
93
+ macd_ratio: Optional[float] = None
94
+ dif_value: Optional[float] = None
95
+ n_pivots: int = 0
96
+ trend: str = ''
97
+ extras: dict = field(default_factory=dict)
98
+ diverge_grade: Optional[DivergenceGrade] = None
99
+
100
+
101
+ def merge_klines(df: pd.DataFrame) -> pd.DataFrame:
102
+ if len(df) == 0:
103
+ return df.copy()
104
+ h = df['high'].values; l = df['low'].values; d = df['date'].values
105
+ out_h, out_l, out_d, out_idx = [h[0]], [l[0]], [d[0]], [0]
106
+ for i in range(1, len(df)):
107
+ ph, pl = out_h[-1], out_l[-1]; ch, cl = h[i], l[i]
108
+ direction = 1 if (len(out_h) >= 2 and out_h[-1] >= out_h[-2]) else (1 if len(out_h) < 2 else -1)
109
+ contained_a = ph >= ch and pl <= cl
110
+ contained_b = ch >= ph and cl <= pl
111
+ if contained_a or contained_b:
112
+ if direction >= 0:
113
+ out_h[-1] = max(ph, ch); out_l[-1] = max(pl, cl)
114
+ else:
115
+ out_h[-1] = min(ph, ch); out_l[-1] = min(pl, cl)
116
+ out_idx[-1] = i
117
+ else:
118
+ out_h.append(ch); out_l.append(cl); out_d.append(d[i]); out_idx.append(i)
119
+ return pd.DataFrame({'date': out_d, 'high': out_h, 'low': out_l, 'orig_idx': out_idx})
120
+
121
+
122
+ def find_fractals(merged: pd.DataFrame) -> list:
123
+ res = []
124
+ n = len(merged)
125
+ if n < 3:
126
+ return res
127
+ h = merged['high'].values; l = merged['low'].values; d = merged['date'].values
128
+ hi = h[1:-1]; hp = h[:-2]; hn = h[2:]
129
+ li = l[1:-1]; lp = l[:-2]; ln = l[2:]
130
+ top = (hi > hp) & (hi > hn) & (li >= lp) & (li >= ln)
131
+ bot = (li < lp) & (li < ln) & (hi <= hp) & (hi <= hn)
132
+ idxs = np.nonzero(top | bot)[0]
133
+ if len(idxs) == 0:
134
+ return res
135
+ # 仅对被选中的(稀疏)分型点构造 Timestamp, 避免对全序列逐根转换
136
+ is_top = top # 局部别名
137
+ for j in idxs:
138
+ i = j + 1
139
+ if is_top[j]:
140
+ res.append(Fractal(i, pd.Timestamp(d[i]), 'top', float(h[i]), float(h[i]), float(l[i])))
141
+ else:
142
+ res.append(Fractal(i, pd.Timestamp(d[i]), 'bottom', float(l[i]), float(h[i]), float(l[i])))
143
+ return res
144
+
145
+
146
+ def find_bis(fractals: list, min_k: int = 4) -> list:
147
+ if len(fractals) < 2:
148
+ return []
149
+ cleaned = [fractals[0]]
150
+ for fx in fractals[1:]:
151
+ last = cleaned[-1]
152
+ if fx.kind == last.kind:
153
+ if fx.kind == 'top' and fx.price > last.price:
154
+ cleaned[-1] = fx
155
+ elif fx.kind == 'bottom' and fx.price < last.price:
156
+ cleaned[-1] = fx
157
+ else:
158
+ cleaned.append(fx)
159
+ alt = [cleaned[0]]
160
+ for fx in cleaned[1:]:
161
+ if fx.kind != alt[-1].kind and fx.idx - alt[-1].idx >= min_k - 1:
162
+ alt.append(fx)
163
+ elif fx.kind != alt[-1].kind:
164
+ continue
165
+ bis = []
166
+ for i in range(len(alt) - 1):
167
+ a, b = alt[i], alt[i+1]
168
+ if a.kind == b.kind:
169
+ continue
170
+ direction = 'up' if b.kind == 'top' else 'down'
171
+ bis.append(Bi(start=a, end=b, direction=direction, bars=b.idx - a.idx,
172
+ high=max(a.price, b.price), low=min(a.price, b.price)))
173
+ return bis
174
+
175
+
176
+ def _find_feature_fractal(std: list, seg_dir: str):
177
+ """[保留] 供调试/对照用的非增量实现; 主路径已改用 _first_feature_fractal_incremental。"""
178
+ up = (seg_dir == 'up')
179
+ for i in range(1, len(std) - 1):
180
+ a = std[i-1]; b = std[i]; c = std[i+1]
181
+ if up:
182
+ if b['high'] > a['high'] and b['high'] > c['high'] \
183
+ and b['low'] > a['low'] and b['low'] > c['low']:
184
+ return (i, b.get('has_gap_before', False))
185
+ else:
186
+ if b['low'] < a['low'] and b['low'] < c['low'] \
187
+ and b['high'] < a['high'] and b['high'] < c['high']:
188
+ return (i, b.get('has_gap_before', False))
189
+ return None
190
+
191
+
192
+ def _first_feature_fractal_incremental(bis, start_i, end_i, seg_dir):
193
+ """增量构建特征序列, 在第一个特征分型被"锁定"时立即返回。
194
+
195
+ 锁定条件: 出现特征分型(a,b,c)后, 再追加一个新的标准元素(即 c 之后
196
+ 已有一个不被包含的元素 d)。此时 c 不会再被向后合并改变, 分型 b 的
197
+ 左右高低关系已固定, 与"先把整段展开再找首个分型"语义等价。
198
+ 返回 (b_first_bi_idx, b_has_gap_before) 或 None。
199
+ """
200
+ feat_dir = 'down' if seg_dir == 'up' else 'up'
201
+ up = (seg_dir == 'up')
202
+ std = [] # 每元素: [high, low, bi_idx, has_gap_before, first_bi_idx]
203
+ for k in range(start_i, end_i + 1):
204
+ b = bis[k]
205
+ if b.direction != feat_dir:
206
+ continue
207
+ ch = b.high; cl = b.low
208
+ if not std:
209
+ std.append([ch, cl, k, False, k]); continue
210
+ prev = std[-1]
211
+ ph = prev[0]; pl = prev[1]
212
+ contained = (ph >= ch and pl <= cl) or (ch >= ph and cl <= pl)
213
+ if contained:
214
+ if up:
215
+ prev[0] = ph if ph > ch else ch
216
+ prev[1] = pl if pl > cl else cl
217
+ else:
218
+ prev[0] = ph if ph < ch else ch
219
+ prev[1] = pl if pl < cl else cl
220
+ prev[2] = k
221
+ continue
222
+ std.append([ch, cl, k, gap_flag(cl, ch, ph, pl)] + [k])
223
+ # 锁定检查: 需要至少4个已定型元素, 才能保证倒数第3个(候选分型b)
224
+ # 的右邻c已被其后元素d终结、不会再被向后合并。
225
+ if len(std) >= 4:
226
+ a = std[-4]; bm = std[-3]; c = std[-2]
227
+ if up:
228
+ ok = (bm[0] > a[0] and bm[0] > c[0] and bm[1] > a[1] and bm[1] > c[1])
229
+ else:
230
+ ok = (bm[1] < a[1] and bm[1] < c[1] and bm[0] < a[0] and bm[0] < c[0])
231
+ if ok:
232
+ return (bm[4], bm[3])
233
+ # 收尾: 末端无后继元素, 用最终 std 找首个内部分型(与原实现等价)
234
+ for i in range(1, len(std) - 1):
235
+ a = std[i-1]; bm = std[i]; c = std[i+1]
236
+ if up:
237
+ ok = (bm[0] > a[0] and bm[0] > c[0] and bm[1] > a[1] and bm[1] > c[1])
238
+ else:
239
+ ok = (bm[1] < a[1] and bm[1] < c[1] and bm[0] < a[0] and bm[0] < c[0])
240
+ if ok:
241
+ return (bm[4], bm[3])
242
+ return None
243
+
244
+
245
+ def gap_flag(cl, ch, ph, pl):
246
+ return (cl > ph) or (ch < pl)
247
+
248
+
249
+ def _seq_fractal_confirms_reversal(bis, start_i, end_i, cur_dir):
250
+ fr = _first_feature_fractal_incremental(bis, start_i, end_i, cur_dir)
251
+ if fr is None:
252
+ return (False, None)
253
+ feat_first_bi, has_gap = fr
254
+ seg_end_bi = feat_first_bi - 1
255
+ if seg_end_bi <= start_i:
256
+ return (False, None)
257
+ if has_gap:
258
+ opp = 'down' if cur_dir == 'up' else 'up'
259
+ fr2 = _first_feature_fractal_incremental(bis, feat_first_bi, end_i, opp)
260
+ if fr2 is None:
261
+ return (False, None)
262
+ return (True, seg_end_bi)
263
+
264
+
265
+ def find_segs(bis: list) -> list:
266
+ n = len(bis)
267
+ if n < 3:
268
+ return []
269
+ base = bis[0].start.price
270
+ look = min(3, n)
271
+ net = bis[look - 1].end.price - base
272
+ cur_dir = 'up' if net > 0 else 'down'
273
+ segs = []
274
+ i = 0
275
+ while i < n - 2:
276
+ confirmed, seg_end_bi = _seq_fractal_confirms_reversal(bis, i, n - 1, cur_dir)
277
+ if confirmed and seg_end_bi > i:
278
+ seg_bis = bis[i:seg_end_bi + 1]
279
+ net_up = seg_bis[-1].end.price > seg_bis[0].start.price
280
+ if (cur_dir == 'up') == net_up:
281
+ segs.append(Seg(start=bis[i].start, end=seg_bis[-1].end, direction=cur_dir,
282
+ bis=seg_bis, high=max(b.high for b in seg_bis),
283
+ low=min(b.low for b in seg_bis), confirmed=True))
284
+ i = seg_end_bi + 1
285
+ cur_dir = 'down' if cur_dir == 'up' else 'up'
286
+ continue
287
+ alt = 'down' if cur_dir == 'up' else 'up'
288
+ confirmed2, seg_end_bi2 = _seq_fractal_confirms_reversal(bis, i, n - 1, alt)
289
+ if confirmed2 and seg_end_bi2 > i:
290
+ seg_bis = bis[i:seg_end_bi2 + 1]
291
+ net_up = seg_bis[-1].end.price > seg_bis[0].start.price
292
+ if (alt == 'up') == net_up:
293
+ segs.append(Seg(start=seg_bis[0].start, end=seg_bis[-1].end, direction=alt,
294
+ bis=seg_bis, high=max(b.high for b in seg_bis),
295
+ low=min(b.low for b in seg_bis), confirmed=True))
296
+ i = seg_end_bi2 + 1
297
+ cur_dir = 'down' if alt == 'up' else 'up'
298
+ continue
299
+ break
300
+ if i < n - 1 and (n - i) >= 1:
301
+ seg_bis = bis[i:]
302
+ if len(seg_bis) >= 1:
303
+ net_up = seg_bis[-1].end.price > seg_bis[0].start.price
304
+ d = 'up' if net_up else 'down'
305
+ segs.append(Seg(start=seg_bis[0].start, end=seg_bis[-1].end, direction=d,
306
+ bis=seg_bis, high=max(b.high for b in seg_bis),
307
+ low=min(b.low for b in seg_bis), confirmed=False))
308
+ return segs
309
+
310
+
311
+ PIVOT_UPGRADE_SPAN_DAYS = 540
312
+
313
+
314
+ def find_pivots(bis: list, segs: Optional[list] = None) -> list:
315
+ confirmed_segs = [s for s in (segs or []) if getattr(s, 'confirmed', True)]
316
+ units = confirmed_segs if len(confirmed_segs) >= 3 else bis
317
+ using_segs = units is confirmed_segs
318
+ pivots = []; n = len(units)
319
+ if n < 3:
320
+ return pivots
321
+
322
+ bi_pos = {id(b): k for k, b in enumerate(bis)}
323
+
324
+ def _unit_bi_range(u):
325
+ if hasattr(u, 'bis'):
326
+ idxs = [bi_pos[id(b)] for b in u.bis if id(b) in bi_pos]
327
+ return (min(idxs), max(idxs)) if idxs else (0, 0)
328
+ k = bi_pos.get(id(u), 0)
329
+ return (k, k)
330
+
331
+ def _unit_bi_indices(unit_list):
332
+ out = []
333
+ for u in unit_list:
334
+ a, b = _unit_bi_range(u)
335
+ out.extend(range(a, b + 1))
336
+ return sorted(set(out))
337
+
338
+ def _unit_high_date(u):
339
+ return u.start.date if u.start.price >= u.end.price else u.end.date
340
+
341
+ def _unit_low_date(u):
342
+ return u.start.date if u.start.price <= u.end.price else u.end.date
343
+
344
+ max_ext = PIVOT_MAX_EXTEND_SEGS if PIVOT_MAX_EXTEND_SEGS else 10 ** 9
345
+
346
+ i = 0
347
+ while i <= n - 3:
348
+ b1, b2, b3 = units[i], units[i+1], units[i+2]
349
+ r1 = (b1.low, b1.high)
350
+ r2 = (b2.low, b2.high)
351
+ r3 = (b3.low, b3.high)
352
+ zg = min(r1[1], r2[1], r3[1]); zd = max(r1[0], r2[0], r3[0])
353
+ if zg > zd:
354
+ direction = b1.direction; zg_orig, zd_orig = zg, zd; gg, dd = zg, zd
355
+ highs = [r1[1], r2[1], r3[1]]; lows = [r1[0], r2[0], r3[0]]
356
+ zg_bi = (b1, b2, b3)[highs.index(zg_orig)]
357
+ zd_bi = (b1, b2, b3)[lows.index(zd_orig)]
358
+ zg_d = _unit_high_date(zg_bi)
359
+ zd_d = _unit_low_date(zd_bi)
360
+ gg_d, dd_d = zg_d, zd_d
361
+ zn_dir = direction
362
+ gn_list = []; dn_list = []
363
+ for bb in (b1, b2, b3):
364
+ if bb.direction == zn_dir:
365
+ gn_list.append(max(bb.start.price, bb.end.price))
366
+ dn_list.append(min(bb.start.price, bb.end.price))
367
+ piv_units = [i, i+1, i+2]; j = i + 3
368
+ capped = False
369
+ while j < n:
370
+ if (len(piv_units) - 3) >= max_ext:
371
+ capped = True
372
+ break
373
+ bj = units[j]; lo_j = bj.low; hi_j = bj.high
374
+ if hi_j >= zd_orig and lo_j <= zg_orig:
375
+ if hi_j > gg:
376
+ gg = hi_j; gg_d = _unit_high_date(bj)
377
+ if lo_j < dd:
378
+ dd = lo_j; dd_d = _unit_low_date(bj)
379
+ if bj.direction == zn_dir:
380
+ gn_list.append(hi_j); dn_list.append(lo_j)
381
+ piv_units.append(j); j += 1
382
+ else:
383
+ break
384
+ g_val = min(gn_list) if gn_list else zg_orig
385
+ d_val = max(dn_list) if dn_list else zd_orig
386
+ piv_bis = _unit_bi_indices([units[k] for k in piv_units]) if using_segs else piv_units
387
+ p_start = b1.start.date
388
+ p_end = units[piv_units[-1]].end.date
389
+ piv = Pivot(start_date=p_start, end_date=p_end,
390
+ zg=zg_orig, zd=zd_orig, gg=gg, dd=dd, bis=piv_bis, direction=direction,
391
+ zg_date=zg_d, zd_date=zd_d, gg_date=gg_d, dd_date=dd_d,
392
+ g=g_val, d=d_val, capped=capped)
393
+ try:
394
+ span_days = (pd.Timestamp(p_end) - pd.Timestamp(p_start)).days
395
+ except Exception:
396
+ span_days = 0
397
+ if capped or span_days > PIVOT_UPGRADE_SPAN_DAYS:
398
+ piv.upgraded_level = 'weekly'
399
+ pivots.append(piv)
400
+ i = piv_units[-1] + 1
401
+ else:
402
+ i += 1
403
+
404
+ for k in range(1, len(pivots)):
405
+ prev, cur = pivots[k-1], pivots[k]
406
+ no_overlap = (cur.dd > prev.gg) or (cur.gg < prev.dd)
407
+ if no_overlap:
408
+ cur.state = 'new'
409
+ else:
410
+ cur.state = 'expand'
411
+
412
+ for k in range(len(pivots) - 1):
413
+ cur = pivots[k]
414
+ nxt = pivots[k + 1]
415
+ gap_start = cur.bis[-1] + 1
416
+ gap_end = nxt.bis[0]
417
+ gap_bis = bis[gap_start:gap_end] if gap_end > gap_start else []
418
+ if len(gap_bis) >= 2:
419
+ leave = gap_bis[:max(1, len(gap_bis) // 2)]
420
+ pull = gap_bis[max(1, len(gap_bis) // 2):]
421
+ leave_trend = len(leave) >= 3
422
+ pull_trend = len(pull) >= 3
423
+ if leave_trend and not pull_trend:
424
+ cur.death_combo = 'trend+consol'
425
+ elif leave_trend and pull_trend:
426
+ cur.death_combo = 'trend+counter'
427
+ else:
428
+ cur.death_combo = 'consol+counter'
429
+ return pivots
430
+
431
+
432
+ def classify_trend(pivots: list) -> str:
433
+ if len(pivots) < 2:
434
+ return 'consolidation'
435
+ p1, p2 = pivots[-2], pivots[-1]
436
+ if p2.dd > p1.gg:
437
+ return 'up_trend'
438
+ if p2.gg < p1.dd:
439
+ return 'down_trend'
440
+ if (p2.zg < p1.zd and p2.gg >= p1.dd) or (p2.zd > p1.zg and p2.dd <= p1.gg):
441
+ return 'expanding'
442
+ return 'consolidation'
443
+
444
+
445
+ def count_trend_pivots(pivots: list) -> int:
446
+ if not pivots:
447
+ return 0
448
+ if len(pivots) == 1:
449
+ return 1
450
+ cnt = 1
451
+ for k in range(len(pivots) - 1, 0, -1):
452
+ p_prev, p_cur = pivots[k - 1], pivots[k]
453
+ if p_cur.dd > p_prev.gg:
454
+ cnt += 1
455
+ elif p_cur.gg < p_prev.dd:
456
+ cnt += 1
457
+ else:
458
+ break
459
+ return cnt
460
+
461
+
462
+ def calc_macd(close: pd.Series, fast=12, slow=26, signal=9):
463
+ ema_fast = close.ewm(span=fast, adjust=False).mean()
464
+ ema_slow = close.ewm(span=slow, adjust=False).mean()
465
+ dif = ema_fast - ema_slow
466
+ dea = dif.ewm(span=signal, adjust=False).mean()
467
+ macd_bar = 2 * (dif - dea)
468
+ return dif, dea, macd_bar
469
+
470
+
471
+ def macd_area_between(start_date, end_date, bar_series, date_series, direction):
472
+ mask = (date_series >= start_date) & (date_series <= end_date)
473
+ vals = bar_series[mask]
474
+ if len(vals) == 0:
475
+ return 0.0
476
+ if direction == 'up':
477
+ return float(vals.clip(lower=0).sum())
478
+ return float(vals.clip(upper=0).abs().sum())
479
+
480
+
481
+ def dif_extreme_in(start_date, end_date, dif_series, date_series, kind='peak'):
482
+ mask = (date_series >= start_date) & (date_series <= end_date)
483
+ vals = dif_series[mask]
484
+ if len(vals) == 0:
485
+ return 0.0
486
+ return float(vals.max()) if kind == 'peak' else float(vals.min())
487
+
488
+
489
+ class ChanAnalyzer:
490
+ DIVERGE_RATIO = 0.80
491
+ PIVOT_TOLERANCE = 0.02
492
+ MIN_BI_BARS = 4
493
+ DIF_TOLERANCE = 0.01
494
+
495
+ CFG = {
496
+ 'b1_allow_consol_diverge': True,
497
+ 'b3s3_first_pullback_only': False,
498
+ 'b2s2_anchor_to_first': False,
499
+ 'b2_macd_zero_pullback': False,
500
+ 'drop_upgraded_pivots': False,
501
+ # L88-90 中阴阶段MACD精确运用: 中阴判定除BOLL收口外, 加入MACD特征
502
+ # 'off' = 维持原判定(仅BOLL收口+末笔未离开中枢)
503
+ # 'and' = 须同时满足"黄白线绕0轴缠绕"(更严格, 减少误判中阴而拦截的好买点)
504
+ # 'or' = 满足其一即算中阴(更宽松, 拦截更多)
505
+ 'zhongyin_macd_mode': 'or', # 实测'or'最优: 累计收益+35pp(383%→418%), 胜率45.3%→46.8%
506
+ }
507
+
508
+ def __init__(self, df: pd.DataFrame):
509
+ self.df_raw = df.reset_index(drop=True)
510
+ self.close = self.df_raw['close']
511
+ self.dif, self.dea, self.macd_bar = calc_macd(self.close)
512
+ self.merged = merge_klines(self.df_raw)
513
+ self.fractals = find_fractals(self.merged)
514
+ self.bis = find_bis(self.fractals, min_k=self.MIN_BI_BARS)
515
+ self._bi_index = {id(b): k for k, b in enumerate(self.bis)}
516
+ self.segs = find_segs(self.bis)
517
+ self.pivots_all = find_pivots(self.bis, self.segs)
518
+ if self.CFG.get('drop_upgraded_pivots'):
519
+ last_upg_idx = -1
520
+ for k, p in enumerate(self.pivots_all):
521
+ if p.upgraded_level:
522
+ last_upg_idx = k
523
+ operative = [p for k, p in enumerate(self.pivots_all)
524
+ if k > last_upg_idx and not p.upgraded_level]
525
+ self.pivots = operative
526
+ else:
527
+ self.pivots = self.pivots_all
528
+ self.trend = classify_trend(self.pivots)
529
+
530
+ @property
531
+ def n_bis(self): return len(self.bis)
532
+ @property
533
+ def n_segs(self): return len(self.segs)
534
+ @property
535
+ def n_pivots(self): return len(self.pivots)
536
+ @property
537
+ def n_pivots_all(self): return len(self.pivots_all)
538
+ @property
539
+ def n_trend_pivots(self): return count_trend_pivots(self.pivots)
540
+ @property
541
+ def has_upgraded_pivot(self):
542
+ return any(p.upgraded_level for p in self.pivots_all)
543
+
544
+ @staticmethod
545
+ def _ds(ts):
546
+ ts = pd.Timestamp(ts)
547
+ if ts.hour == 0 and ts.minute == 0:
548
+ return ts.strftime('%Y-%m-%d')
549
+ return ts.strftime('%Y-%m-%d %H:%M')
550
+
551
+ def _validate_abc(self, direction: str):
552
+ if self.n_pivots < 2:
553
+ return None
554
+ last_piv = self.pivots[-1]
555
+ prev_piv = self.pivots[-2]
556
+ ratio = len(prev_piv.bis) / max(len(last_piv.bis), 1)
557
+ if not (1 / 3 <= ratio <= 3):
558
+ return None
559
+ a_start_idx = prev_piv.bis[0]
560
+ a_end_idx = last_piv.bis[0]
561
+ if a_end_idx <= a_start_idx:
562
+ return None
563
+ c_start_idx = last_piv.bis[-1] + 1
564
+ if c_start_idx >= self.n_bis:
565
+ return None
566
+ return {'a_start_idx': a_start_idx, 'a_end_idx': a_end_idx,
567
+ 'b_pivot': last_piv, 'c_start_idx': c_start_idx}
568
+
569
+ def _validate_abc_consol(self, direction: str):
570
+ if self.n_pivots < 1:
571
+ return None
572
+ piv = self.pivots[-1]
573
+ a_end_idx = piv.bis[0]
574
+ if a_end_idx <= 0:
575
+ return None
576
+ c_start_idx = piv.bis[-1] + 1
577
+ if c_start_idx >= self.n_bis:
578
+ return None
579
+ want = 'down' if direction == 'down' else 'up'
580
+ a_start_idx = a_end_idx
581
+ for k in range(a_end_idx - 1, -1, -1):
582
+ a_start_idx = k
583
+ if k >= 1 and self.bis[k].direction != want and self.bis[k-1].direction != want:
584
+ a_start_idx = k + 1
585
+ break
586
+ if a_start_idx >= a_end_idx:
587
+ return None
588
+ return {'a_start_idx': a_start_idx, 'a_end_idx': a_end_idx,
589
+ 'b_pivot': piv, 'c_start_idx': c_start_idx}
590
+
591
+ def _check_c_new_extreme(self, c_start_idx: int, direction: str):
592
+ want = 'up' if direction == 'up' else 'down'
593
+ c_bis = [self.bis[k] for k in range(c_start_idx, self.n_bis)
594
+ if self.bis[k].direction == want]
595
+ if not c_bis:
596
+ return False, None
597
+ last_piv = self.pivots[-1] if self.pivots else None
598
+ if direction == 'up':
599
+ c_ext = max(b.end.price for b in c_bis)
600
+ prior = (last_piv.gg if last_piv is not None
601
+ else max((b.high for b in self.bis[:c_start_idx]), default=0.0))
602
+ return c_ext > prior * (1 - 0.001), c_ext
603
+ else:
604
+ c_ext = min(b.end.price for b in c_bis)
605
+ prior = (last_piv.dd if last_piv is not None
606
+ else min((b.low for b in self.bis[:c_start_idx]), default=1e18))
607
+ return c_ext < prior * (1 + 0.001), c_ext
608
+
609
+ def _b_returns_to_zero(self, pivot) -> bool:
610
+ dates = self.df_raw['date']
611
+ mask = (dates >= pivot.start_date) & (dates <= pivot.end_date)
612
+ dif_b = self.dif[mask]
613
+ dea_b = self.dea[mask]
614
+ if len(dif_b) == 0 or len(dea_b) == 0:
615
+ return False
616
+ def near_zero(x):
617
+ if x.min() <= 0 <= x.max():
618
+ return True
619
+ return x.abs().min() < max(float(x.abs().max()), 1e-9) * 0.25
620
+ return near_zero(dif_b) and near_zero(dea_b)
621
+
622
+ def detect_double_pullback_to_zero(self, window: int = 40) -> bool:
623
+ n = len(self.dif)
624
+ if n < 10:
625
+ return False
626
+ dif = self.dif.iloc[-min(window, n):].reset_index(drop=True)
627
+ dea = self.dea.iloc[-min(window, n):].reset_index(drop=True)
628
+ hist = self.macd_bar.iloc[-min(window, n):].reset_index(drop=True)
629
+ scale = max(float(dif.abs().max()), float(dea.abs().max()), 1e-9)
630
+ near = scale * 0.25
631
+ zero_pulls = [i for i in range(len(dif))
632
+ if abs(float(dif.iloc[i])) <= near and abs(float(dea.iloc[i])) <= near]
633
+ if len(zero_pulls) < 2:
634
+ return False
635
+ def peak_between(left, right):
636
+ vals = dif.iloc[left + 1:right]
637
+ if len(vals) < 2:
638
+ return None
639
+ rel = int(vals.idxmax())
640
+ return float(dif.iloc[rel]), float(hist.iloc[max(left + 1, rel - 3):rel + 1].clip(lower=0).sum())
641
+ def trough_between(left, right):
642
+ vals = dif.iloc[left + 1:right]
643
+ if len(vals) < 2:
644
+ return None
645
+ rel = int(vals.idxmin())
646
+ return float(dif.iloc[rel]), float(hist.iloc[max(left + 1, rel - 3):rel + 1].clip(upper=0).abs().sum())
647
+ first_pull, second_pull = zero_pulls[-2], zero_pulls[-1]
648
+ p1, p2 = peak_between(first_pull, second_pull), peak_between(second_pull, len(dif))
649
+ if p1 and p2 and p1[0] > 0 and p2[0] > 0 and p2[0] < p1[0] and p2[1] <= p1[1]:
650
+ return True
651
+ t1, t2 = trough_between(first_pull, second_pull), trough_between(second_pull, len(dif))
652
+ if t1 and t2 and t1[0] < 0 and t2[0] < 0 and t2[0] > t1[0] and t2[1] <= t1[1]:
653
+ return True
654
+ return False
655
+
656
+ def divergence_strength_by_position(self) -> str:
657
+ if len(self.dif) < 5:
658
+ return 'strong_pullback'
659
+ dif_now = float(self.dif.iloc[-1])
660
+ dif_abs_max = float(self.dif.abs().max())
661
+ if dif_abs_max <= 1e-9:
662
+ return 'strong_pullback'
663
+ if abs(dif_now) >= dif_abs_max * 0.85:
664
+ return 'weak_pullback'
665
+ return 'strong_pullback'
666
+
667
+ def classify_post_divergence(self, direction: str) -> dict:
668
+ if not self.pivots or self.n_bis < 2:
669
+ return {'evolution': 'unknown', 'reason': '无中枢或笔不足'}
670
+ last_piv = self.pivots[-1]
671
+ after_idx = last_piv.bis[-1] + 1
672
+ def first_reversal_seg(want_dir):
673
+ for s in self.segs:
674
+ if not getattr(s, 'confirmed', True) or s.direction != want_dir:
675
+ continue
676
+ try:
677
+ first_idx = self._bi_index[id(s.bis[0])]
678
+ except KeyError:
679
+ continue
680
+ if first_idx >= after_idx:
681
+ return s
682
+ return None
683
+ if direction == 'down':
684
+ rebound = first_reversal_seg('up')
685
+ if rebound is None:
686
+ rebound = None
687
+ for b in self.bis[after_idx:]:
688
+ if b.direction == 'up':
689
+ rebound = b; break
690
+ if rebound is None:
691
+ return {'evolution': 'unknown', 'reason': '无反弹笔'}
692
+ if rebound.high < last_piv.zd:
693
+ return {'evolution': 'case1_extend',
694
+ 'reason': f'反弹高{rebound.high:.3f}<最后中枢ZD{last_piv.zd:.3f} → 第29课情况①未回中枢(最弱,宜尽快撤)'}
695
+ if rebound.high >= last_piv.zd:
696
+ return {'evolution': 'case2_3_turn',
697
+ 'reason': f'反弹回到中枢(高{rebound.high:.3f}≥ZD{last_piv.zd:.3f}) → 第29课情况②③转折(可持有等三买)'}
698
+ return {'evolution': 'case1_extend',
699
+ 'reason': f'反弹未回中枢(高{rebound.high:.3f}<ZD{last_piv.zd:.3f}) → 偏向中枢扩展'}
700
+ else:
701
+ pullback = first_reversal_seg('down')
702
+ if pullback is None:
703
+ pullback = None
704
+ for b in self.bis[after_idx:]:
705
+ if b.direction == 'down':
706
+ pullback = b; break
707
+ if pullback is None:
708
+ return {'evolution': 'unknown', 'reason': '无回落笔'}
709
+ if pullback.low > last_piv.zg:
710
+ return {'evolution': 'case1_extend',
711
+ 'reason': f'回落低{pullback.low:.3f}>最后中枢ZG{last_piv.zg:.3f} → 第29课情况①未回中枢(最弱)'}
712
+ if pullback.low <= last_piv.zg:
713
+ return {'evolution': 'case2_3_turn',
714
+ 'reason': f'回落回到中枢(低{pullback.low:.3f}≤ZG{last_piv.zg:.3f}) → 第29课情况②③转折'}
715
+ return {'evolution': 'case1_extend',
716
+ 'reason': f'回落未回中枢 → 偏向中枢扩展'}
717
+
718
+ def macd_wrap_zero(self, window: int = 15) -> bool:
719
+ """L88-90: 中阴阶段的MACD特征 —— 黄白线(DIF/DEA)绕0轴缠绕。
720
+ 近window根K线中, DIF与DEA的绝对值大多压在历史摆幅的25%以内即视为缠绕。"""
721
+ n = len(self.dif)
722
+ if n < window + 5:
723
+ return False
724
+ dif = self.dif.iloc[-window:]
725
+ dea = self.dea.iloc[-window:]
726
+ scale = max(float(self.dif.abs().tail(120).max()),
727
+ float(self.dea.abs().tail(120).max()), 1e-9)
728
+ near = scale * 0.25
729
+ frac = float(((dif.abs() <= near) & (dea.abs() <= near)).mean())
730
+ return frac >= 0.6
731
+
732
+ def macd_clarity(self, window: int = 60) -> dict:
733
+ """L50: 本级别MACD的"清晰度" —— 柱子面积幅度 + 黄白线分离度, 归一化打分。
734
+ 清晰度高的级别其背驰判定更可靠; 多级别联立时应优先采信清晰级别的MACD结论。"""
735
+ n = len(self.dif)
736
+ if n < 10:
737
+ return {'score': 0.0, 'label': '数据不足'}
738
+ w = min(window, n)
739
+ dif = self.dif.iloc[-w:]
740
+ dea = self.dea.iloc[-w:]
741
+ hist = self.macd_bar.iloc[-w:]
742
+ px = max(float(self.close.iloc[-1]), 1e-9)
743
+ bar_amp = float(hist.abs().mean()) / px # 柱子相对幅度
744
+ sep = float((dif - dea).abs().mean()) / px # 黄白线分离度
745
+ # 黄白线贴着0轴乱绕 → 不清晰
746
+ wrap_penalty = 0.5 if self.macd_wrap_zero() else 1.0
747
+ score = (bar_amp * 0.6 + sep * 0.4) * 1e3 * wrap_penalty
748
+ label = '清晰' if score >= 1.0 else ('一般' if score >= 0.4 else '模糊(黄白线/柱子贴0轴)')
749
+ return {'score': round(score, 3), 'label': label,
750
+ 'bar_amp': round(bar_amp * 1e3, 3), 'sep': round(sep * 1e3, 3)}
751
+
752
+ def in_zhongyin(self) -> dict:
753
+ n = len(self.close)
754
+ if n < 20 or not self.pivots:
755
+ return {'in_zhongyin': False, 'boll_squeeze': False, 'reason': '数据不足'}
756
+ ma = self.close.rolling(20).mean()
757
+ sd = self.close.rolling(20).std()
758
+ if ma.iloc[-1] and ma.iloc[-1] > 0:
759
+ width = float((4 * sd.iloc[-1]) / ma.iloc[-1])
760
+ else:
761
+ width = 0.0
762
+ wseries = (4 * sd / ma).dropna().tail(60)
763
+ squeeze = bool(len(wseries) >= 20 and width <= wseries.quantile(0.30))
764
+ osc = self.zhongshu_oscillation_monitor()
765
+ macd_wrap = self.macd_wrap_zero()
766
+ dbl_pull = self.detect_double_pullback_to_zero()
767
+ mode = self.CFG.get('zhongyin_macd_mode', 'off')
768
+ if mode == 'and':
769
+ in_zy = (not osc.get('alert', False)) and squeeze and macd_wrap
770
+ elif mode == 'or':
771
+ in_zy = (not osc.get('alert', False)) and (squeeze or macd_wrap)
772
+ else:
773
+ in_zy = (not osc.get('alert', False)) and squeeze
774
+ reason = (f"BOLL带宽{width:.3f}{'(收口→中阴)' if squeeze else '(开口)'}; "
775
+ f"MACD黄白线{'绕0轴缠绕(L88-90中阴特征)' if macd_wrap else '已展开'}"
776
+ f"{'; 双回拉0轴(L89: 中阴结束转折预备)' if dbl_pull else ''}; {osc.get('reason','')}")
777
+ return {'in_zhongyin': in_zy, 'boll_squeeze': squeeze,
778
+ 'macd_wrap_zero': macd_wrap, 'double_pullback_zero': dbl_pull,
779
+ 'reason': reason}
780
+
781
+ def zhongshu_oscillation_monitor(self) -> dict:
782
+ if not self.pivots or self.n_bis < 1:
783
+ return {'alert': False, 'direction': '', 'reason': '无中枢'}
784
+ last_piv = self.pivots[-1]
785
+ cur = self.bis[-1]
786
+ if cur.low > last_piv.zg:
787
+ return {'alert': True, 'direction': 'up',
788
+ 'reason': f'第92课: 末笔({cur.low:.3f}~{cur.high:.3f})已离开中枢上沿ZG{last_piv.zg:.3f} → 向上变盘预警'}
789
+ if cur.high < last_piv.zd:
790
+ return {'alert': True, 'direction': 'down',
791
+ 'reason': f'第92课: 末笔({cur.low:.3f}~{cur.high:.3f})已离开中枢下沿ZD{last_piv.zd:.3f} → 向下变盘预警'}
792
+ return {'alert': False, 'direction': '', 'reason': '末笔仍在中枢区间内, 中枢震荡延续'}
793
+
794
+ def bottom_construction_state(self) -> str:
795
+ has_b1 = self.detect_b1() is not None
796
+ has_b3 = self.detect_b3() is not None
797
+ has_s3 = self.detect_s3() is not None
798
+ if has_s3:
799
+ return 'failed'
800
+ if has_b3:
801
+ return 'completed'
802
+ if has_b1:
803
+ return 'constructing'
804
+ return 'none'
805
+
806
+ def _seg_index_range(self, seg):
807
+ m = self._bi_index
808
+ try:
809
+ return m[id(seg.bis[0])], m[id(seg.bis[-1])]
810
+ except KeyError:
811
+ return None
812
+
813
+ def _bi_exit_pullback_fallback(self, pivot, exit_dir: str, pull_dir: str):
814
+ pe = pivot.bis[-1]
815
+ leave = pull = None
816
+ for k in range(pe + 1, self.n_bis):
817
+ b = self.bis[k]
818
+ if leave is None:
819
+ if b.direction == exit_dir:
820
+ leave = b
821
+ continue
822
+ if b.direction == pull_dir:
823
+ pull = b
824
+ break
825
+ if leave is None or pull is None:
826
+ return None
827
+ return leave, pull
828
+
829
+ def _last_exit_pullback_segments(self, pivot, exit_dir: str, pull_dir: str):
830
+ pivot_end = pivot.bis[-1]
831
+ seq = []
832
+ for s in (self.segs or []):
833
+ if not getattr(s, 'confirmed', True):
834
+ continue
835
+ rng = self._seg_index_range(s)
836
+ if rng is None:
837
+ continue
838
+ first_idx, last_idx = rng
839
+ if last_idx < pivot_end:
840
+ continue
841
+ seq.append((s, first_idx, last_idx))
842
+ first_only = self.CFG.get('b3s3_first_pullback_only')
843
+ if first_only:
844
+ for i in range(len(seq) - 1):
845
+ leave, lf, _ = seq[i]
846
+ pull = seq[i + 1][0]
847
+ if leave.direction == exit_dir and pull.direction == pull_dir and lf >= pivot_end:
848
+ return leave, pull
849
+ else:
850
+ for i in range(len(seq) - 1, 0, -1):
851
+ pull, _, _ = seq[i]
852
+ leave, _, _ = seq[i - 1]
853
+ if leave.direction == exit_dir and pull.direction == pull_dir:
854
+ return leave, pull
855
+ return self._bi_exit_pullback_fallback(pivot, exit_dir, pull_dir)
856
+
857
+ def assess_divergence(self, a_start, a_end, c_start, c_end, direction: str) -> DivergenceGrade:
858
+ dates = self.df_raw['date']
859
+ n_tp = self.n_trend_pivots
860
+ is_trend_div = n_tp >= 2
861
+ a_area = macd_area_between(a_start, a_end, self.macd_bar, dates, direction)
862
+ c_area = macd_area_between(c_start, c_end, self.macd_bar, dates, direction)
863
+ if a_area <= 1e-9:
864
+ return DivergenceGrade('NONE', False, False, 0.0, a_area, c_area, 0.0, 0.0,
865
+ direction, 'A段MACD面积为0,无可比基准',
866
+ is_trend_divergence=is_trend_div, n_trend_pivots=n_tp)
867
+ ratio = c_area / a_area
868
+ area_ok = ratio < self.DIVERGE_RATIO
869
+ TOL = self.DIF_TOLERANCE
870
+ if direction == 'up':
871
+ a_dif = dif_extreme_in(a_start, a_end, self.dif, dates, 'peak')
872
+ c_dif = dif_extreme_in(c_start, c_end, self.dif, dates, 'peak')
873
+ dif_ok = c_dif < a_dif * (1 - TOL)
874
+ else:
875
+ a_dif = dif_extreme_in(a_start, a_end, self.dif, dates, 'trough')
876
+ c_dif = dif_extreme_in(c_start, c_end, self.dif, dates, 'trough')
877
+ dif_ok = c_dif > a_dif * (1 - TOL)
878
+ ext_label = 'DIF峰' if direction == 'up' else 'DIF谷'
879
+ div_kind = '趋势背驰' if is_trend_div else '盘整背驰'
880
+ if area_ok and dif_ok:
881
+ grade = 'STRONG'
882
+ reason = f'���准{div_kind}(面积+DIF均满足)|A面积:{a_area:.4f} C面积:{c_area:.4f}(比值{ratio:.1%}<{self.DIVERGE_RATIO:.0%})|{ext_label} A:{a_dif:.4f} C:{c_dif:.4f}|{n_tp}中枢'
883
+ elif area_ok and not dif_ok:
884
+ grade = 'WEAK'
885
+ reason = f'{div_kind}信号(面积触发)|A面积:{a_area:.4f} C面积:{c_area:.4f}(比值{ratio:.1%}<{self.DIVERGE_RATIO:.0%})|{n_tp}中枢'
886
+ elif dif_ok and not area_ok:
887
+ grade = 'WEAK'
888
+ reason = f'{div_kind}信号(DIF触发)|{ext_label} A:{a_dif:.4f} C:{c_dif:.4f}|{n_tp}中枢'
889
+ else:
890
+ grade = 'NONE'
891
+ reason = f'无背驰(两判据均不满足)|面积比{ratio:.1%}>={self.DIVERGE_RATIO:.0%}|{ext_label} A:{a_dif:.4f} C:{c_dif:.4f}'
892
+ return DivergenceGrade(grade, area_ok, dif_ok, ratio, a_area, c_area, a_dif, c_dif,
893
+ direction, reason, is_trend_divergence=is_trend_div, n_trend_pivots=n_tp)
894
+
895
+ def _find_prev_b1(self) -> tuple:
896
+ if not self.pivots:
897
+ return (None, '')
898
+ last_piv = self.pivots[-1]
899
+ pivot_first_bi_idx = last_piv.bis[0]
900
+ if pivot_first_bi_idx <= 0:
901
+ return (None, '')
902
+ downs = []
903
+ for k in range(pivot_first_bi_idx - 1, -1, -1):
904
+ b = self.bis[k]
905
+ downs.append(b)
906
+ if b.direction == 'up' and k - 1 >= 0 and self.bis[k-1].direction == 'down':
907
+ if len(downs) >= 4:
908
+ break
909
+ down_bis = [b for b in downs if b.direction == 'down']
910
+ if not down_bis:
911
+ return (None, '')
912
+ b1 = min(down_bis, key=lambda b: b.end.price)
913
+ return (b1.end.price, self._ds(b1.end.date))
914
+
915
+ def _find_prev_b2(self) -> tuple:
916
+ b1_price, b1_date_str = self._find_prev_b1()
917
+ if b1_price is None or not self.pivots:
918
+ return (None, '')
919
+ b1_date = pd.Timestamp(b1_date_str)
920
+ last_piv = self.pivots[-1]
921
+ right_bi_idx = last_piv.bis[-1] + 1
922
+ for b in self.bis[:right_bi_idx]:
923
+ if b.direction != 'down':
924
+ continue
925
+ if b.end.date <= b1_date:
926
+ continue
927
+ if b.end.price > b1_price + 1e-9:
928
+ return (b.end.price, self._ds(b.end.date))
929
+ return (None, '')
930
+
931
+ def _find_prev_s1(self) -> tuple:
932
+ if not self.pivots:
933
+ return (None, '')
934
+ last_piv = self.pivots[-1]
935
+ pivot_first_bi_idx = last_piv.bis[0]
936
+ if pivot_first_bi_idx <= 0:
937
+ return (None, '')
938
+ ups = []
939
+ for k in range(pivot_first_bi_idx - 1, -1, -1):
940
+ b = self.bis[k]
941
+ ups.append(b)
942
+ if b.direction == 'down' and k - 1 >= 0 and self.bis[k-1].direction == 'up':
943
+ if len(ups) >= 4:
944
+ break
945
+ up_bis = [b for b in ups if b.direction == 'up']
946
+ if not up_bis:
947
+ return (None, '')
948
+ s1 = max(up_bis, key=lambda b: b.end.price)
949
+ return (s1.end.price, self._ds(s1.end.date))
950
+
951
+ def _find_prev_s2(self) -> tuple:
952
+ s1_price, s1_date_str = self._find_prev_s1()
953
+ if s1_price is None or not self.pivots:
954
+ return (None, '')
955
+ s1_date = pd.Timestamp(s1_date_str)
956
+ last_piv = self.pivots[-1]
957
+ right_bi_idx = last_piv.bis[-1] + 1
958
+ for b in self.bis[:right_bi_idx]:
959
+ if b.direction != 'up':
960
+ continue
961
+ if b.end.date <= s1_date:
962
+ continue
963
+ if b.end.price < s1_price - 1e-9:
964
+ return (b.end.price, self._ds(b.end.date))
965
+ return (None, '')
966
+
967
+ def detect_b1(self) -> Optional[Signal]:
968
+ if self.n_bis < 5:
969
+ return None
970
+ cur = self.bis[-1]
971
+ if cur.direction != 'down':
972
+ return None
973
+ dif_now = float(self.dif.iloc[-1])
974
+ if dif_now >= 0:
975
+ return None
976
+ trend_ok = (self.n_pivots >= 2 and self.trend == 'down_trend')
977
+ consol_ok = (self.CFG.get('b1_allow_consol_diverge') and self.n_pivots >= 1)
978
+ if not (trend_ok or consol_ok):
979
+ return None
980
+ abc = self._validate_abc('down')
981
+ if abc is None and consol_ok:
982
+ abc = self._validate_abc_consol('down')
983
+ if abc is None:
984
+ return None
985
+ c_ok, c_low = self._check_c_new_extreme(abc['c_start_idx'], 'down')
986
+ if not c_ok:
987
+ return None
988
+ last_piv = self.pivots[-1]
989
+ a_down = [self.bis[k] for k in range(abc['a_start_idx'], abc['a_end_idx'])
990
+ if self.bis[k].direction == 'down']
991
+ c_down = [self.bis[k] for k in range(abc['c_start_idx'], self.n_bis)
992
+ if self.bis[k].direction == 'down']
993
+ if not a_down or not c_down:
994
+ return None
995
+ dg = self.assess_divergence(a_down[0].start.date, a_down[-1].end.date,
996
+ c_down[0].start.date, c_down[-1].end.date, 'down')
997
+ if dg.grade == 'NONE':
998
+ return None
999
+ div_kind = '趋势底背驰' if dg.is_trend_divergence else '盘整底背驰(第27课)'
1000
+ b_zero = self._b_returns_to_zero(last_piv)
1001
+ dbl_pull = self.detect_double_pullback_to_zero()
1002
+ pos_strength = self.divergence_strength_by_position()
1003
+ post_evo = self.classify_post_divergence('down')
1004
+ a_low_bi = min(a_down, key=lambda b: b.end.price)
1005
+ c_low_bi = min(c_down, key=lambda b: b.end.price)
1006
+ return Signal(kind='B1', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]),
1007
+ reason=f'{div_kind}一买|{self.n_pivots}中枢|ABC三段{"+B回0轴" if b_zero else ""}|{dg.reason}|DIF={dif_now:.4f}<0',
1008
+ pivot_zg=last_piv.zg, pivot_zd=last_piv.zd, macd_ratio=dg.area_ratio, dif_value=dif_now,
1009
+ n_pivots=self.n_pivots, trend=self.trend,
1010
+ extras={'a_seg': (a_down[0].start.date, a_down[-1].end.date, a_down[0].start.price, a_down[-1].end.price),
1011
+ 'diverge_grade': dg.grade,
1012
+ 'a_low': float(a_low_bi.end.price),
1013
+ 'a_low_date': self._ds(a_low_bi.end.date),
1014
+ 'b1_price': float(cur.end.price),
1015
+ 'b1_date': self._ds(cur.end.date),
1016
+ 'macd_grade': dg.grade,
1017
+ 'macd_area_ratio': dg.area_ratio,
1018
+ 'dif_ok': dg.dif_ok,
1019
+ 'area_ok': dg.area_ok,
1020
+ 'b_returns_zero': b_zero,
1021
+ 'double_pullback': dbl_pull,
1022
+ 'pos_strength': pos_strength,
1023
+ 'post_evolution': post_evo['evolution'],
1024
+ 'c_new_low': c_low,
1025
+ 'c_new_low_date': self._ds(c_low_bi.end.date),
1026
+ 'n_trend_pivots': self.n_trend_pivots,
1027
+ 'price_date': self._ds(cur.end.date),
1028
+ 'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
1029
+ 'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
1030
+ 'pivot_start_date': self._ds(last_piv.start_date),
1031
+ 'pivot_end_date': self._ds(last_piv.end_date)},
1032
+ diverge_grade=dg)
1033
+
1034
+ def detect_b2(self) -> Optional[Signal]:
1035
+ if self.n_bis < 4:
1036
+ return None
1037
+ cur = self.bis[-1]
1038
+ if cur.direction != 'down':
1039
+ return None
1040
+ prev_downs = [b for b in self.bis[:-1] if b.direction == 'down']
1041
+ if not prev_downs:
1042
+ return None
1043
+ prev = prev_downs[-1]
1044
+ if not (cur.low >= prev.low and cur.end.price > prev.end.price):
1045
+ return None
1046
+ cur_price = float(self.close.iloc[-1])
1047
+ if cur_price < cur.end.price * (1 - self.PIVOT_TOLERANCE):
1048
+ return None
1049
+ if self.CFG.get('b2s2_anchor_to_first'):
1050
+ b1_anchor, _ = self._find_prev_b1()
1051
+ if b1_anchor is None:
1052
+ return None
1053
+ if cur.end.price < b1_anchor - 1e-9:
1054
+ return None
1055
+ dif_now = float(self.dif.iloc[-1])
1056
+ if self.CFG.get('b2_macd_zero_pullback'):
1057
+ look = self.dif.tail(12)
1058
+ crossed_up = bool((look > 0).any())
1059
+ if not crossed_up:
1060
+ return None
1061
+ if dif_now < -self.DIF_TOLERANCE:
1062
+ return None
1063
+ last_piv = self.pivots[-1] if self.pivots else None
1064
+ b1_price, b1_date = prev.end.price, self._ds(prev.end.date)
1065
+ return Signal(kind='B2', date=self.df_raw['date'].iloc[-1], price=cur_price,
1066
+ reason=f'二买:一买后回踩不破|当前低{cur.end.price:.3f}>一买{b1_price:.3f}|二买不以背驰为成立条件(第21课)',
1067
+ pivot_zg=None, pivot_zd=None,
1068
+ macd_ratio=None, dif_value=dif_now, n_pivots=self.n_pivots, trend=self.trend,
1069
+ extras={'prev_low': prev.end.price, 'cur_low': cur.end.price,
1070
+ 'cur_low_date': self._ds(cur.end.date),
1071
+ 'prev_low_date': self._ds(prev.end.date),
1072
+ 'b1_price': b1_price,
1073
+ 'b1_date': b1_date,
1074
+ 'price_date': self._ds(cur.end.date),
1075
+ 'context_pivot_zg': last_piv.zg if last_piv else None,
1076
+ 'context_pivot_zd': last_piv.zd if last_piv else None,
1077
+ 'context_pivot_zg_date': self._ds(last_piv.zg_date) if last_piv and last_piv.zg_date else '',
1078
+ 'context_pivot_zd_date': self._ds(last_piv.zd_date) if last_piv and last_piv.zd_date else '',
1079
+ 'context_pivot_start_date': self._ds(last_piv.start_date) if last_piv else '',
1080
+ 'context_pivot_end_date': self._ds(last_piv.end_date) if last_piv else ''},
1081
+ diverge_grade=None)
1082
+
1083
+ def detect_b3(self) -> Optional[Signal]:
1084
+ if self.n_bis < 5 or self.n_pivots < 1:
1085
+ return None
1086
+ late_trend_b3 = self.n_trend_pivots >= 2
1087
+ last_piv = self.pivots[-1]
1088
+ zg, zd = last_piv.zg, last_piv.zd
1089
+ piv_height = zg - zd
1090
+ cur = self.bis[-1]
1091
+ if cur.direction != 'up':
1092
+ return None
1093
+ pair = self._last_exit_pullback_segments(last_piv, 'up', 'down')
1094
+ if pair is None:
1095
+ return None
1096
+ exit_seg, pull_seg = pair
1097
+ if not (exit_seg.low <= zg * (1 + self.PIVOT_TOLERANCE) and exit_seg.high > zg):
1098
+ return None
1099
+ if pull_seg.low < zg * (1 - self.PIVOT_TOLERANCE):
1100
+ return None
1101
+ leaves_pivot = pull_seg.low >= zg
1102
+ cur_price = float(self.close.iloc[-1])
1103
+ if cur_price <= zg:
1104
+ return None
1105
+ ex_amp = exit_seg.high - exit_seg.low
1106
+ if piv_height > 0 and ex_amp < piv_height * 0.5:
1107
+ return None
1108
+ if len(last_piv.bis) < 3:
1109
+ return None
1110
+ confirm_txt = '回踩离枢确认(新中枢生成)' if leaves_pivot else '回踩贴ZG(容差内,新中枢待确认)'
1111
+ b1_price, b1_date = self._find_prev_b1()
1112
+ b2_price, b2_date = self._find_prev_b2()
1113
+ warn_txt = '|第二个以上同向中枢,实盘宜改用低级别一买' if late_trend_b3 else ''
1114
+ return Signal(kind='B3', date=self.df_raw['date'].iloc[-1], price=cur_price,
1115
+ reason=f'标准三买|ZG={zg:.3f},ZD={zd:.3f}|线段离枢:{exit_seg.low:.3f}→{exit_seg.high:.3f}(幅度{ex_amp:.3f})|线段回试低{pull_seg.low:.3f}|{confirm_txt}|当前{cur_price:.3f}>ZG{warn_txt}',
1116
+ pivot_zg=zg, pivot_zd=zd, macd_ratio=None, dif_value=float(self.dif.iloc[-1]),
1117
+ n_pivots=self.n_pivots, trend=self.trend,
1118
+ extras={'exit_seg': (exit_seg.low, exit_seg.high),
1119
+ 'pull_low': pull_seg.low,
1120
+ 'pull_low_date': self._ds(pull_seg.end.date),
1121
+ 'exit_start_date': self._ds(exit_seg.start.date),
1122
+ 'exit_end_date': self._ds(exit_seg.end.date),
1123
+ 'b1_price': b1_price,
1124
+ 'b1_date': b1_date,
1125
+ 'b2_price': b2_price,
1126
+ 'b2_date': b2_date,
1127
+ 'piv_bi_count': len(last_piv.bis), 'leaves_pivot': leaves_pivot,
1128
+ 'late_trend_b3': late_trend_b3,
1129
+ 'price_date': self._ds(self.df_raw['date'].iloc[-1]),
1130
+ 'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
1131
+ 'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
1132
+ 'pivot_start_date': self._ds(last_piv.start_date),
1133
+ 'pivot_end_date': self._ds(last_piv.end_date)},
1134
+ diverge_grade=None)
1135
+
1136
+ def detect_s1(self) -> Optional[Signal]:
1137
+ if self.n_bis < 5:
1138
+ return None
1139
+ cur = self.bis[-1]
1140
+ if cur.direction != 'up':
1141
+ return None
1142
+ trend_ok = (self.n_pivots >= 2 and self.trend == 'up_trend')
1143
+ consol_ok = (self.CFG.get('b1_allow_consol_diverge') and self.n_pivots >= 1)
1144
+ if not (trend_ok or consol_ok):
1145
+ return None
1146
+ abc = self._validate_abc('up')
1147
+ if abc is None and consol_ok:
1148
+ abc = self._validate_abc_consol('up')
1149
+ if abc is None:
1150
+ return None
1151
+ last_piv = self.pivots[-1]
1152
+ a_start_idx = abc['a_start_idx']; a_end_idx = abc['a_end_idx']
1153
+ if a_end_idx <= a_start_idx:
1154
+ return None
1155
+ a_up_bis = [self.bis[k] for k in range(a_start_idx, a_end_idx) if self.bis[k].direction == 'up']
1156
+ if not a_up_bis:
1157
+ return None
1158
+ a_high = max(b.end.price for b in a_up_bis)
1159
+ c_start_idx = last_piv.bis[-1] + 1
1160
+ c_up_bis = [self.bis[k] for k in range(c_start_idx, self.n_bis) if self.bis[k].direction == 'up']
1161
+ if not c_up_bis:
1162
+ return None
1163
+ c_high = max(b.end.price for b in c_up_bis)
1164
+ if c_high <= a_high:
1165
+ return None
1166
+ a_high_bi = max(a_up_bis, key=lambda b: b.end.price)
1167
+ dg = self.assess_divergence(a_up_bis[0].start.date, a_up_bis[-1].end.date,
1168
+ c_up_bis[0].start.date, c_up_bis[-1].end.date, 'up')
1169
+ if dg.grade == 'NONE':
1170
+ return None
1171
+ b_zero = self._b_returns_to_zero(last_piv)
1172
+ dbl_pull = self.detect_double_pullback_to_zero()
1173
+ pos_strength = self.divergence_strength_by_position()
1174
+ post_evo = self.classify_post_divergence('up')
1175
+ dif_now = float(self.dif.iloc[-1])
1176
+ c_high_bi = max(c_up_bis, key=lambda b: b.end.price)
1177
+ return Signal(kind='S1', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]),
1178
+ reason=f'一卖|上涨趋势{self.n_pivots}中枢|价创新高C{c_high:.3f}>A段高{a_high:.3f}{"+B回0轴" if b_zero else ""}|{dg.reason}',
1179
+ pivot_zg=last_piv.zg, pivot_zd=last_piv.zd, macd_ratio=dg.area_ratio, dif_value=dif_now,
1180
+ n_pivots=self.n_pivots, trend=self.trend,
1181
+ extras={'a_high': a_high, 'c_high': c_high, 'a_area': dg.a_area, 'c_area': dg.c_area,
1182
+ 'diverge_grade': dg.grade,
1183
+ 'a_high_date': self._ds(a_high_bi.end.date),
1184
+ 'b_returns_zero': b_zero,
1185
+ 'double_pullback': dbl_pull,
1186
+ 'pos_strength': pos_strength,
1187
+ 'post_evolution': post_evo['evolution'],
1188
+ 'c_high_date': self._ds(c_high_bi.end.date),
1189
+ 'price_date': self._ds(cur.end.date),
1190
+ 'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
1191
+ 'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
1192
+ 'pivot_start_date': self._ds(last_piv.start_date),
1193
+ 'pivot_end_date': self._ds(last_piv.end_date)},
1194
+ diverge_grade=dg)
1195
+
1196
+ def detect_s2(self) -> Optional[Signal]:
1197
+ if self.n_bis < 4:
1198
+ return None
1199
+ cur = self.bis[-1]
1200
+ if cur.direction != 'up':
1201
+ return None
1202
+ prev_ups = [b for b in self.bis[:-1] if b.direction == 'up']
1203
+ if not prev_ups:
1204
+ return None
1205
+ prev = prev_ups[-1]
1206
+ if cur.high < prev.high and cur.end.price < prev.end.price:
1207
+ cur_price = float(self.close.iloc[-1])
1208
+ if cur_price > cur.end.price * (1 + self.PIVOT_TOLERANCE):
1209
+ return None
1210
+ if self.CFG.get('b2s2_anchor_to_first'):
1211
+ s1_anchor, _ = self._find_prev_s1()
1212
+ if s1_anchor is None:
1213
+ return None
1214
+ if cur.end.price > s1_anchor + 1e-9:
1215
+ return None
1216
+ dif_now = float(self.dif.iloc[-1])
1217
+ last_piv = self.pivots[-1] if self.pivots else None
1218
+ s1_price, s1_date = prev.end.price, self._ds(prev.end.date)
1219
+ return Signal(kind='S2', date=self.df_raw['date'].iloc[-1], price=cur_price,
1220
+ reason=f'二卖:一卖后反弹不破|当前高{cur.end.price:.3f}<一卖{s1_price:.3f}|二卖不以背驰为成立条件(第21课)',
1221
+ pivot_zg=None, pivot_zd=None,
1222
+ dif_value=dif_now, n_pivots=self.n_pivots, trend=self.trend,
1223
+ extras={'prev_high': prev.end.price, 'cur_high': cur.end.price,
1224
+ 'cur_high_date': self._ds(cur.end.date),
1225
+ 'prev_high_date': self._ds(prev.end.date),
1226
+ 's1_price': s1_price, 's1_date': s1_date,
1227
+ 'price_date': self._ds(cur.end.date),
1228
+ 'context_pivot_zg': last_piv.zg if last_piv else None,
1229
+ 'context_pivot_zd': last_piv.zd if last_piv else None,
1230
+ 'context_pivot_zg_date': self._ds(last_piv.zg_date) if last_piv and last_piv.zg_date else '',
1231
+ 'context_pivot_zd_date': self._ds(last_piv.zd_date) if last_piv and last_piv.zd_date else '',
1232
+ 'context_pivot_start_date': self._ds(last_piv.start_date) if last_piv else '',
1233
+ 'context_pivot_end_date': self._ds(last_piv.end_date) if last_piv else ''},
1234
+ diverge_grade=None)
1235
+ return None
1236
+
1237
+ def detect_s3(self) -> Optional[Signal]:
1238
+ if self.n_bis < 5 or self.n_pivots < 1:
1239
+ return None
1240
+ last_piv = self.pivots[-1]
1241
+ zg, zd = last_piv.zg, last_piv.zd
1242
+ if len(self.bis) < 3:
1243
+ return None
1244
+ cur = self.bis[-1]
1245
+ if cur.direction != 'down':
1246
+ return None
1247
+ s1_price, s1_date = self._find_prev_s1()
1248
+ s2_price, s2_date = self._find_prev_s2()
1249
+ base_dates = {'price_date': self._ds(self.df_raw['date'].iloc[-1]),
1250
+ 's1_price': s1_price, 's1_date': s1_date,
1251
+ 's2_price': s2_price, 's2_date': s2_date,
1252
+ 'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
1253
+ 'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
1254
+ 'pivot_start_date': self._ds(last_piv.start_date),
1255
+ 'pivot_end_date': self._ds(last_piv.end_date)}
1256
+ pair = self._last_exit_pullback_segments(last_piv, 'down', 'up')
1257
+ if pair is None:
1258
+ return None
1259
+ exit_seg, pull_seg = pair
1260
+ if not (exit_seg.high >= zd * (1 - self.PIVOT_TOLERANCE) and exit_seg.low < zd):
1261
+ return None
1262
+ if pull_seg.high > zd * (1 + self.PIVOT_TOLERANCE):
1263
+ return None
1264
+ if float(self.close.iloc[-1]) >= zd:
1265
+ return None
1266
+ return Signal(kind='S3', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]),
1267
+ reason=f'标准三卖|ZD={zd:.3f}|线段离枢低{exit_seg.low:.3f}<ZD|线段回抽高{pull_seg.high:.3f}未过ZD',
1268
+ pivot_zg=zg, pivot_zd=zd, dif_value=float(self.dif.iloc[-1]),
1269
+ n_pivots=self.n_pivots, trend=self.trend, extras=base_dates, diverge_grade=None)
1270
+
1271
+ def get_signal(self) -> Optional[Signal]:
1272
+ for fn in [self.detect_s1, self.detect_s2, self.detect_s3]:
1273
+ sig = fn()
1274
+ if sig is not None:
1275
+ return sig
1276
+ for fn in [self.detect_b3, self.detect_b2, self.detect_b1]:
1277
+ sig = fn()
1278
+ if sig is not None:
1279
+ return sig
1280
+ return None
1281
+
1282
+ def get_all_signals(self) -> list:
1283
+ all_sigs = []
1284
+ for fn in [self.detect_b1, self.detect_b2, self.detect_b3,
1285
+ self.detect_s1, self.detect_s2, self.detect_s3]:
1286
+ sig = fn()
1287
+ if sig is not None:
1288
+ all_sigs.append(sig)
1289
+ return all_sigs
1290
+
1291
+ def l36_segment_note(self) -> str:
1292
+ """L36 结合律: 走势分解的唯一性靠结合律保证 —— a+A+b+B+c 的划分中, 同一段
1293
+ K线不能既归前段又归后段。本引擎线段划分采用特征序列分型(标准缠论)处理
1294
+ 包含关系, 分型一旦确认即锁定段的归属, 等价于结合律的程序化执行。
1295
+ 该函数对当前末端给出"是否存在划分歧义"的提示。"""
1296
+ if len(self.segs) < 2:
1297
+ return '第36课: 线段不足2段, 无划分歧义问题'
1298
+ last = self.segs[-1]
1299
+ n_last = len(getattr(last, 'bis', []) or [])
1300
+ if n_last < 3:
1301
+ return (f'第36课: 末段仅{n_last}笔(<3), 末端划分尚未唯一确认 —— '
1302
+ f'当下操作应按两种归属做完全分类预案, 等待特征序列分型锁定')
1303
+ return '第36课: 末段≥3笔且特征序列分型已锁定, 当前划分唯一, 无歧义'
1304
+
1305
+ def diagnose(self) -> dict:
1306
+ cur_price = float(self.close.iloc[-1]) if len(self.close) else 0.0
1307
+ last = self.bis[-1] if self.bis else None
1308
+ out = {k: [] for k in ('B1', 'B2', 'B3', 'S1', 'S2', 'S3')}
1309
+ def add(k, ok, msg):
1310
+ out[k].append(('✓' if ok else '✗') + ' ' + msg)
1311
+ add('B1', self.n_bis >= 5, f'笔数 {self.n_bis} >= 5')
1312
+ add('B1', self.n_pivots >= 2, f'中枢数 {self.n_pivots} >= 2')
1313
+ add('B1', self.trend == 'down_trend', f'当前走势={self.trend}, B1要求下跌趋势')
1314
+ add('B1', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, B1要求向下')
1315
+ add('B1', float(self.dif.iloc[-1]) < 0 if len(self.dif) else False, f'DIF={float(self.dif.iloc[-1]):.4f}, B1要求DIF<0')
1316
+ abc_down = self._validate_abc('down')
1317
+ add('B1', abc_down is not None, 'A/B/C三段背驰结构成立')
1318
+ if abc_down is not None:
1319
+ c_ok, c_low = self._check_c_new_extreme(abc_down['c_start_idx'], 'down')
1320
+ add('B1', c_ok, f'C段创新低{"" if c_low is None else f"({c_low:.3f})"}')
1321
+ add('B2', self.n_bis >= 4, f'笔数 {self.n_bis} >= 4')
1322
+ add('B2', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, B2要求回踩向下')
1323
+ prev_downs = [b for b in self.bis[:-1] if b.direction == 'down'] if last else []
1324
+ add('B2', bool(prev_downs), '存在同一轮前一个下跌低点作为一买锚')
1325
+ if last and prev_downs:
1326
+ prev = prev_downs[-1]
1327
+ add('B2', last.low >= prev.low and last.end.price > prev.end.price,
1328
+ f'回踩不创新低: 本次低{last.end.price:.3f} > 一买/前低{prev.end.price:.3f}')
1329
+ add('B2', cur_price >= last.end.price * (1 - self.PIVOT_TOLERANCE),
1330
+ f'现价{cur_price:.3f}未跌破B2回踩锚{last.end.price:.3f}; 跌破则二买失效')
1331
+ add('B3', self.n_pivots >= 1, f'中枢数 {self.n_pivots} >= 1')
1332
+ add('B3', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, B3要求向上确认')
1333
+ if self.pivots:
1334
+ p = self.pivots[-1]
1335
+ pair = self._last_exit_pullback_segments(p, 'up', 'down')
1336
+ add('B3', pair is not None, '存在已确认线段级别的向上离枢 + 向下回试')
1337
+ if pair is not None:
1338
+ exit_seg, pull_seg = pair
1339
+ add('B3', exit_seg.low <= p.zg * (1 + self.PIVOT_TOLERANCE) and exit_seg.high > p.zg,
1340
+ f'离枢线段突破ZG: {exit_seg.low:.3f}~{exit_seg.high:.3f}, ZG={p.zg:.3f}')
1341
+ add('B3', pull_seg.low >= p.zg * (1 - self.PIVOT_TOLERANCE),
1342
+ f'回试低点{pull_seg.low:.3f}不破ZG={p.zg:.3f}')
1343
+ add('B3', cur_price > p.zg, f'现价{cur_price:.3f}站上ZG={p.zg:.3f}')
1344
+ add('S1', self.n_bis >= 5, f'笔数 {self.n_bis} >= 5')
1345
+ add('S1', self.n_pivots >= 2, f'中枢数 {self.n_pivots} >= 2')
1346
+ add('S1', self.trend == 'up_trend', f'当前走势={self.trend}, S1要求上涨趋势')
1347
+ add('S1', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, S1要求向上')
1348
+ abc_up = self._validate_abc('up')
1349
+ add('S1', abc_up is not None, 'A/B/C三段顶背驰结构成立')
1350
+ if abc_up is not None:
1351
+ c_ok, c_high = self._check_c_new_extreme(abc_up['c_start_idx'], 'up')
1352
+ add('S1', c_ok, f'C段创新高{"" if c_high is None else f"({c_high:.3f})"}')
1353
+ add('S2', self.n_bis >= 4, f'笔数 {self.n_bis} >= 4')
1354
+ add('S2', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, S2要求反弹向上')
1355
+ prev_ups = [b for b in self.bis[:-1] if b.direction == 'up'] if last else []
1356
+ add('S2', bool(prev_ups), '存在同一轮前一个上涨高点作为一卖锚')
1357
+ if last and prev_ups:
1358
+ prev = prev_ups[-1]
1359
+ add('S2', last.high < prev.high and last.end.price < prev.end.price,
1360
+ f'反弹不创新高: 本次高{last.end.price:.3f} < 一卖/前高{prev.end.price:.3f}')
1361
+ add('S2', cur_price <= last.end.price * (1 + self.PIVOT_TOLERANCE),
1362
+ f'现价{cur_price:.3f}未重新升破S2反弹锚{last.end.price:.3f}; 升破则二卖失效')
1363
+ add('S3', self.n_pivots >= 1, f'中枢数 {self.n_pivots} >= 1')
1364
+ add('S3', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, S3要求向下确认')
1365
+ if self.pivots:
1366
+ p = self.pivots[-1]
1367
+ pair = self._last_exit_pullback_segments(p, 'down', 'up')
1368
+ add('S3', pair is not None, '存在已确认线段级别的向下离枢 + 向上回抽')
1369
+ if pair is not None:
1370
+ exit_seg, pull_seg = pair
1371
+ add('S3', exit_seg.high >= p.zd * (1 - self.PIVOT_TOLERANCE) and exit_seg.low < p.zd,
1372
+ f'离枢线段跌破ZD: {exit_seg.low:.3f}~{exit_seg.high:.3f}, ZD={p.zd:.3f}')
1373
+ add('S3', pull_seg.high <= p.zd * (1 + self.PIVOT_TOLERANCE),
1374
+ f'回抽高点{pull_seg.high:.3f}不破ZD={p.zd:.3f}')
1375
+ add('S3', cur_price < p.zd, f'现价{cur_price:.3f}跌破ZD={p.zd:.3f}')
1376
+ return out
1377
+
1378
+
1379
+ class SameLevelDecomposition:
1380
+ def __init__(self, analyzer: 'ChanAnalyzer'):
1381
+ self.an = analyzer
1382
+ self.segs = analyzer.segs
1383
+
1384
+ def current_phase(self) -> dict:
1385
+ if len(self.segs) < 2:
1386
+ return {'seg_dir': '', 'stage': 'unknown', 'action': 'WATCH',
1387
+ 'reason': '线段不足, 无法做同级别分解'}
1388
+ last = self.segs[-1]
1389
+ prev = self.segs[-2]
1390
+ seg_dir = last.direction
1391
+ if seg_dir == 'up':
1392
+ stage = 'up_run'
1393
+ prev_up = None
1394
+ for s in reversed(self.segs[:-1]):
1395
+ if s.direction == 'up':
1396
+ prev_up = s; break
1397
+ if prev_up is None:
1398
+ return {'seg_dir': seg_dir, 'stage': stage, 'action': 'HOLD',
1399
+ 'reason': '向上段运作中(无前向上段可比), 持有'}
1400
+ if last.high <= prev_up.high:
1401
+ return {'seg_dir': seg_dir, 'stage': stage, 'action': 'SELL',
1402
+ 'reason': f'向上段不创新高({last.high:.3f}≤前高{prev_up.high:.3f}) → 先卖(第38课)'}
1403
+ dg = self.an.assess_divergence(prev_up.start.date, prev_up.end.date,
1404
+ last.start.date, last.end.date, 'up')
1405
+ if dg.grade != 'NONE':
1406
+ return {'seg_dir': seg_dir, 'stage': stage, 'action': 'SELL',
1407
+ 'reason': f'向上段创新高但盘整背驰({dg.grade}) → 卖(第38课)'}
1408
+ return {'seg_dir': seg_dir, 'stage': stage, 'action': 'HOLD',
1409
+ 'reason': '向上段创新高且不背驰 → 持有(第38课)'}
1410
+ else:
1411
+ stage = 'down_run'
1412
+ prev_down = None
1413
+ for s in reversed(self.segs[:-1]):
1414
+ if s.direction == 'down':
1415
+ prev_down = s; break
1416
+ if prev_down is None:
1417
+ return {'seg_dir': seg_dir, 'stage': stage, 'action': 'WATCH',
1418
+ 'reason': '向下段运作中(无前向下段可比), 观望等买点'}
1419
+ if last.low >= prev_down.low:
1420
+ return {'seg_dir': seg_dir, 'stage': stage, 'action': 'BUY',
1421
+ 'reason': f'向下段不创新低({last.low:.3f}≥前低{prev_down.low:.3f}) → 买入(第38课)'}
1422
+ dg = self.an.assess_divergence(prev_down.start.date, prev_down.end.date,
1423
+ last.start.date, last.end.date, 'down')
1424
+ if dg.grade != 'NONE':
1425
+ return {'seg_dir': seg_dir, 'stage': stage, 'action': 'BUY',
1426
+ 'reason': f'向下段创新低但盘整背驰({dg.grade}) → 买入(第38课)'}
1427
+ return {'seg_dir': seg_dir, 'stage': stage, 'action': 'WATCH',
1428
+ 'reason': '向下段创新低且不背驰 → 观望等下跌背驰(第38课)'}
1429
+
1430
+
1431
+ class BottomTracker:
1432
+ def __init__(self):
1433
+ self.state = 'none'
1434
+
1435
+ def update(self, analyzer: 'ChanAnalyzer') -> str:
1436
+ snap = analyzer.bottom_construction_state()
1437
+ if self.state in ('none', 'failed', 'completed'):
1438
+ if snap == 'constructing':
1439
+ self.state = 'constructing'
1440
+ elif snap == 'completed':
1441
+ self.state = 'completed'
1442
+ elif snap == 'failed':
1443
+ self.state = 'failed'
1444
+ else:
1445
+ self.state = 'none'
1446
+ elif self.state == 'constructing':
1447
+ if snap == 'completed':
1448
+ self.state = 'completed'
1449
+ elif snap == 'failed':
1450
+ self.state = 'failed'
1451
+ return self.state
chan_enhance.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ chan_enhance.py —— 缠论系统增强(查漏补缺) · 纯函数版(非Hook)
3
+ """
4
+ from __future__ import annotations
5
+
6
+
7
+ W_REVERSAL = 1.0
8
+ W_TRAP = 0.85
9
+ W_RELAY = 0.6
10
+ W_UNKNOWN = 0.7
11
+
12
+ # 第16课 六种基本走势 → 买法分类的中文名(配合英文 method 一起展示)
13
+ METHOD_CN = {
14
+ 'reversal': '反转式(下跌+盘整+上涨 / 趋势底背驰一买, 力度最强, 权重最高)',
15
+ 'trap': '陷阱式(下跌+上涨, 空头陷阱式二买, 中等力度)',
16
+ 'relay': '中继式(上涨+盘整+上涨, 三买, 趋势中继)',
17
+ 'unknown': '未分类',
18
+ }
19
+
20
+ WEAK_ARM_GAIN = 0.04
21
+ WEAK_GIVEBACK = 0.22
22
+
23
+ L92_EXIT_ENABLE = False
24
+ L92_EXIT_FLOATING_MAX = 0.015
25
+ L92_ZD_BREAK_BUF = 0.0
26
+
27
+
28
+ def classify_buy_method(sig_kind, ml) -> str:
29
+ sig = ml.daily.signal if (ml and ml.daily) else None
30
+ dg = getattr(sig, 'diverge_grade', None) if sig is not None else None
31
+ if sig_kind == 'B1':
32
+ return 'reversal'
33
+ if sig_kind == 'B3':
34
+ return 'relay'
35
+ if sig_kind == 'B2':
36
+ # L37 背驰分辨: 只有"趋势背驰"(≥2个同向中枢后的背驰)才是标准的趋势转折,
37
+ # 对应第16课"下跌+盘整+上涨"反转式买法; 盘整背驰力度弱, 仍按陷阱式处理。
38
+ if (dg is not None and getattr(dg, 'grade', '') == 'STRONG'
39
+ and getattr(dg, 'is_trend_divergence', False)):
40
+ return 'reversal'
41
+ return 'trap'
42
+ return 'unknown'
43
+
44
+
45
+ def l37_divergence_note(ml) -> str:
46
+ """L37 背驰分辨(区分背驰段构成): 趋势背驰=至少两个同向中枢后的背驰, 转折级别高;
47
+ 盘整背驰=单中枢内的力度衰竭, 只保证回拉中枢, 不保证反转。"""
48
+ sig = ml.daily.signal if (ml and ml.daily) else None
49
+ dg = getattr(sig, 'diverge_grade', None) if sig is not None else None
50
+ if dg is None or getattr(dg, 'grade', 'NONE') == 'NONE':
51
+ return ''
52
+ if getattr(dg, 'is_trend_divergence', False):
53
+ return (f'第37课: 趋势背驰({getattr(dg, "n_trend_pivots", "?")}个同向中枢), '
54
+ f'转折至少回拉至最后一个中枢, 大概率反转 → 可按反转式买法重仓')
55
+ return ('第37课: 盘整背驰(背驰段内仅1个中枢), 只保证回拉中枢一次, '
56
+ '不保证趋势反转 → 轻仓短打, 回拉到中枢即考虑兑现')
57
+
58
+
59
+ def buy_method_weight(sig_kind, ml):
60
+ method = classify_buy_method(sig_kind, ml)
61
+ wmap = {'reversal': W_REVERSAL, 'trap': W_TRAP, 'relay': W_RELAY, 'unknown': W_UNKNOWN}
62
+ return method, wmap.get(method, W_UNKNOWN)
63
+
64
+
65
+ def recompute_evo(ml) -> str:
66
+ sig = ml.daily.signal if (ml and ml.daily) else None
67
+ if sig is not None:
68
+ evo = (sig.extras or {}).get('post_evolution', '')
69
+ if evo:
70
+ return evo
71
+ dv = ml.daily if ml else None
72
+ if dv is not None and dv.zd is not None and dv.zg is not None:
73
+ if ml.cur_price < dv.zd:
74
+ return 'case1_extend'
75
+ return ''
76
+
77
+
78
+ def weak_evo_giveback(pos, ml, default_giveback):
79
+ evo = recompute_evo(ml) if ml is not None else (pos.get('post_evo') or '')
80
+ if evo == 'case1_extend':
81
+ return WEAK_ARM_GAIN, WEAK_GIVEBACK
82
+ return None, default_giveback
83
+
84
+
85
+ def l92_should_exit(pos, ml, close_p, floating):
86
+ if not L92_EXIT_ENABLE or ml is None or ml.daily is None:
87
+ return None
88
+ if floating > L92_EXIT_FLOATING_MAX:
89
+ return None
90
+ zd = ml.daily.zd
91
+ if zd is None:
92
+ return None
93
+ if close_p < zd * (1 - L92_ZD_BREAK_BUF) and close_p > pos.get('stop_px', 0):
94
+ return ('L92_EXIT',
95
+ f'第92课中枢震荡监视器: 现价{close_p:.3f}已破日线中枢下沿ZD{zd:.3f}'
96
+ f'(向下变盘) 且浮盈仅{floating:+.1%} → 提前减仓, 不等磨到结构止损')
97
+ return None
98
+
99
+
100
+ def predict_enhance(ml) -> dict:
101
+ out = {}
102
+ if ml is None or ml.daily is None:
103
+ return out
104
+ sig_kind = ml.final_kind or ''
105
+ if sig_kind in ('B1', 'B2', 'B3'):
106
+ method, weight = buy_method_weight(sig_kind, ml)
107
+ out['buy_method'] = method
108
+ out['buy_method_cn'] = METHOD_CN.get(method, method)
109
+ out['suggest_weight'] = weight
110
+ out['l16_note'] = f'第16课: 买法分类={method}〔{METHOD_CN.get(method, method)}〕, 建议仓位权重{weight:.2f}'
111
+ l37 = l37_divergence_note(ml)
112
+ if l37:
113
+ out['l37_note'] = l37
114
+ evo = recompute_evo(ml)
115
+ if evo:
116
+ out['post_evolution'] = evo
117
+ out['evo_hint'] = ('第29课: 最弱形态(反弹/回落未回中枢), 持有宜收紧移动止盈, 尽快兑现'
118
+ if evo == 'case1_extend'
119
+ else '第29课: 形态较强(回到中枢), 可持有等三买/趋势延续')
120
+ zd = ml.daily.zd
121
+ if zd is not None and ml.cur_price < zd:
122
+ out['l92_warn'] = f'第92课: 现价{ml.cur_price:.3f}已在日线中枢下沿ZD{zd:.3f}下方 → 向下变盘预警'
123
+ return out
chan_glue.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ chan_glue.py — wires the user's notebook-style modules together at runtime.
3
+
4
+ The original chan_engine.py / chan_multilevel.py were written as notebook cells:
5
+ chan_multilevel references `ChanAnalyzer` / `Signal` via a commented-out import.
6
+ Because both files use `from __future__ import annotations` and resolve names at
7
+ call time, we can simply inject the symbols into the module namespace — the Chan
8
+ analysis logic itself is left 100% untouched.
9
+
10
+ Also provides a small LRU-cached analyzer factory (the original chan_common.py
11
+ was A-share specific and is not used in the US version).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ from collections import OrderedDict
17
+
18
+ import pandas as pd
19
+
20
+ import chan_engine
21
+ import chan_multilevel
22
+
23
+ # ── inject cross-module symbols (replaces the commented `from chan_engine import …`) ──
24
+ chan_multilevel.ChanAnalyzer = chan_engine.ChanAnalyzer
25
+ chan_multilevel.Signal = chan_engine.Signal
26
+
27
+ # Re-exports for app code
28
+ ChanAnalyzer = chan_engine.ChanAnalyzer
29
+ Signal = chan_engine.Signal
30
+ MultiLevelChan = chan_multilevel.MultiLevelChan
31
+ MultiLevelSignal = chan_multilevel.MultiLevelSignal
32
+ resample_weekly = chan_multilevel.resample_weekly
33
+ resample_monthly = chan_multilevel.resample_monthly
34
+ set_analyzer_factory = chan_multilevel.set_analyzer_factory
35
+
36
+ # ── cached analyzer factory ──────────────────────────────────────────────
37
+ _CACHE: "OrderedDict[str, chan_engine.ChanAnalyzer]" = OrderedDict()
38
+ _CACHE_MAX = 64
39
+
40
+
41
+ def _df_key(level: str, df: pd.DataFrame) -> str:
42
+ n = len(df)
43
+ if n == 0:
44
+ return f"{level}-empty"
45
+ last = str(df['date'].iloc[-1])
46
+ first = str(df['date'].iloc[0])
47
+ tail_close = float(df['close'].iloc[-1])
48
+ raw = f"{level}|{n}|{first}|{last}|{tail_close:.6f}"
49
+ return hashlib.md5(raw.encode()).hexdigest()
50
+
51
+
52
+ def cached_analyzer(level: str, df: pd.DataFrame):
53
+ key = _df_key(level, df)
54
+ if key in _CACHE:
55
+ _CACHE.move_to_end(key)
56
+ return _CACHE[key]
57
+ an = chan_engine.ChanAnalyzer(df.reset_index(drop=True))
58
+ _CACHE[key] = an
59
+ while len(_CACHE) > _CACHE_MAX:
60
+ _CACHE.popitem(last=False)
61
+ return an
62
+
63
+
64
+ def install():
65
+ """Install the cached analyzer factory into MultiLevelChan."""
66
+ set_analyzer_factory(cached_analyzer)
67
+
68
+
69
+ install()
chan_multilevel.py ADDED
@@ -0,0 +1,884 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """缠论多级别联立分析
2
+
3
+ 【本版改动 · 卖点区间套修复 (第24/44课)】
4
+
5
+ 旧逻辑的两个病根(对应回测中"卖在低位"与"提前卖飞"):
6
+ ① 卖在低位: 严格模式要求"30分钟出现三类卖点S3"才印证日线卖点。但S3是
7
+ 次级别已经跌破其中枢、反抽不回的【转折确认点】—— 它出现时价格早已
8
+ 离开顶部一大截。拿S3当卖出闸门, 等于规定"必须先跌下来才准卖", 结构上
9
+ 注定卖不到一卖的高位, 最后落在二卖/三卖的低位。
10
+ ② 提前卖飞: 宽松模式下"30m最后一笔向下"即印证。第24课明示: 小级别背驰
11
+ (更别说仅仅一笔向下)未必引发大级别转折。任何一次30m级别的正常回调都
12
+ 可能把日线WEAK级别的卖点"确认"掉 → 还没到顶就出局。
13
+ 而且旧代码对次级别卖点【不做时间嵌套检查】: 30m在日线C段开始之前的
14
+ 旧背驰也会被当作印证 —— "区间套"三个字里的"区间"丢了。
15
+
16
+ 新逻辑 (CFG['sell_nested_interval']=True):
17
+ 第44课区间套的本义: 大级别进入背驰段后, 在该背驰段【时间区间内】找次级别
18
+ 的背驰段, 再在其中找次次级别的背驰段, 逐级收敛到精确转折点。
19
+ 对日线S1/S2, 次级别印证按优先级:
20
+ ① 嵌套顶背驰: 30m出现S1, 且其C段顶部落在日线背驰段(最后中枢之后→当下)
21
+ 的时间窗口内、顶部价位贴近日线C段高点 → 精确卖点, 卖在高位区。
22
+ ② 次级别转折确认: 30m出现S3, 或30m末笔已跌破其最近中枢下沿ZD(第92课
23
+ 向下变盘) → 转折已确认, 偏晚但必须卖。
24
+ ③ 两者皆无 → 次级别动能未竭, 第24课: 顶未到 → 【不卖】(防卖飞),
25
+ 返回 action=HOLD + sell_armed=True 进入"区间套布防"状态:
26
+ 回测端持仓转入布防, 之后任一条件触发(嵌套背驰出现/破30m中枢ZD/
27
+ 较布防峰值回落超阈值)即离场 —— 既不提前卖飞, 也不一路坐滑梯到三卖。
28
+ S3(日线三卖)不走嵌套背驰: 三卖本身就是转折确认型卖点, 维持原确认逻辑。
29
+ """
30
+ from __future__ import annotations
31
+ from dataclasses import dataclass, field
32
+ from typing import Optional
33
+ import numpy as np
34
+ import pandas as pd
35
+
36
+ # from chan_engine import ChanAnalyzer, Signal
37
+
38
+
39
+ # ── 卖点区间套参数 (第24/44课) ──
40
+ NESTED_TOP_PRICE_TOL = 0.03 # 嵌套顶背驰的顶部须贴近父级C段高点(3%以内), 否则属上一波动能
41
+ NESTED_WINDOW_PAD_DAYS = 3 # 时间嵌套窗口的左侧容差(日)
42
+ SELL_ARM_PEAK_DROP = 0.04 # 布防后较峰值价回落≥4% → 顶部确认离场(回测端使用)
43
+ SELL_ARM_DISARM_BREAK = 0.03 # 布防后强势创新高超3%且卖点消失 → 背驰被消化, 撤防(第26课)
44
+
45
+
46
+ def _default_make_analyzer(level, df):
47
+ return ChanAnalyzer(df.reset_index(drop=True))
48
+
49
+ _MAKE_ANALYZER = _default_make_analyzer
50
+
51
+ def set_analyzer_factory(fn):
52
+ global _MAKE_ANALYZER
53
+ _MAKE_ANALYZER = fn or _default_make_analyzer
54
+
55
+
56
+ def resample_weekly(df_daily: pd.DataFrame) -> pd.DataFrame:
57
+ if df_daily.empty:
58
+ return df_daily.copy()
59
+ d = df_daily.copy()
60
+ d['date'] = pd.to_datetime(d['date'])
61
+ d = d.sort_values('date').reset_index(drop=True)
62
+ wk_period = d['date'].dt.to_period('W-FRI')
63
+ agg = {'open': 'first', 'high': 'max', 'low': 'min', 'close': 'last'}
64
+ if 'volume' in d.columns: agg['volume'] = 'sum'
65
+ if 'amount' in d.columns: agg['amount'] = 'sum'
66
+ g = d.groupby(wk_period, sort=True)
67
+ wk = g.agg(agg)
68
+ last_real = g['date'].max()
69
+ wk = wk.dropna(subset=['open', 'high', 'low', 'close']).reset_index(drop=True)
70
+ wk['date'] = list(last_real.values)
71
+ return wk
72
+
73
+
74
+ def resample_monthly(df_daily: pd.DataFrame) -> pd.DataFrame:
75
+ if df_daily.empty:
76
+ return df_daily.copy()
77
+ d = df_daily.copy()
78
+ d['date'] = pd.to_datetime(d['date'])
79
+ d = d.sort_values('date').reset_index(drop=True)
80
+ mp = d['date'].dt.to_period('M')
81
+ agg = {'open': 'first', 'high': 'max', 'low': 'min', 'close': 'last'}
82
+ if 'volume' in d.columns: agg['volume'] = 'sum'
83
+ if 'amount' in d.columns: agg['amount'] = 'sum'
84
+ g = d.groupby(mp, sort=True)
85
+ mo = g.agg(agg)
86
+ last_real = g['date'].max()
87
+ mo = mo.dropna(subset=['open', 'high', 'low', 'close']).reset_index(drop=True)
88
+ mo['date'] = list(last_real.values)
89
+ return mo
90
+
91
+
92
+ @dataclass
93
+ class LevelView:
94
+ level: str
95
+ trend: str
96
+ n_pivots: int
97
+ n_bis: int
98
+ last_bi_dir: str
99
+ signal: Optional[Signal]
100
+ zg: Optional[float]
101
+ zd: Optional[float]
102
+ dif: Optional[float]
103
+ last_date: Optional[pd.Timestamp]
104
+ diagnostics: dict = field(default_factory=dict)
105
+
106
+
107
+ @dataclass
108
+ class MultiLevelSignal:
109
+ code: str
110
+ analysis_date: pd.Timestamp
111
+ weekly: LevelView
112
+ daily: LevelView
113
+ m30: Optional[LevelView]
114
+ action: str
115
+ confidence: str
116
+ final_kind: str
117
+ cur_price: float
118
+ chain: list = field(default_factory=list)
119
+ blocked_reason: str = ''
120
+ note: str = ''
121
+ confidence_reasons: list = field(default_factory=list)
122
+ diagnostics: dict = field(default_factory=dict)
123
+ monthly: Optional[LevelView] = None
124
+ # ── 卖点区间套布防 (第44课) ──
125
+ sell_armed: bool = False # 日线S1/S2背驰已现、次级别动能未竭 → 持仓布防
126
+ arm_zd: Optional[float] = None # 布防线①: 30m最近中枢下沿ZD(跌破即次级别转折确认)
127
+ arm_high: Optional[float] = None # 父级背驰段C段高点(用于"新高消化背驰"撤防判定)
128
+
129
+ def explain(self) -> str:
130
+ lines = [f' [{self.code}] {self.analysis_date.strftime("%Y-%m-%d")} ¥{self.cur_price:.3f}']
131
+ lines.append(f' 最终: {self.action} 置信度={self.confidence}'
132
+ + (f' 信号={self.final_kind}' if self.final_kind else ''))
133
+ lines.append(' ── 逐级裁决链 ──')
134
+ for lvl, concl, src in self.chain:
135
+ lines.append(f' [{lvl:<7s}] {concl}')
136
+ if src:
137
+ lines.append(f' └ 依据: {src}')
138
+ if self.blocked_reason:
139
+ lines.append(f' ⚠ 拦截: {self.blocked_reason}')
140
+ if self.note:
141
+ lines.append(f' 说明: {self.note}')
142
+ if self.confidence_reasons:
143
+ lines.append(' 置信度降档明细:')
144
+ for i, r in enumerate(self.confidence_reasons, 1):
145
+ lines.append(f' {i}. {r}')
146
+ if self.diagnostics:
147
+ lines.append(' 日线买卖点逐项诊断:')
148
+ for k in ('B1', 'B2', 'B3', 'S1', 'S2', 'S3'):
149
+ rows = self.diagnostics.get(k) or []
150
+ if not rows:
151
+ continue
152
+ ok = all(str(x).startswith('✓') for x in rows)
153
+ lines.append(f' {k} {"满足" if ok else "不满足"}')
154
+ for row in rows:
155
+ lines.append(f' {row}')
156
+ else:
157
+ lines.append(' 日线买卖点逐项诊断: 未生成')
158
+ return '\n'.join(lines)
159
+
160
+
161
+ class MultiLevelChan:
162
+ CFG = {
163
+ 'use_monthly_gate': True,
164
+ 'monthly_ma_filter': False,
165
+ 'require_sublevel_sell_confirm': False,
166
+ 'mode': 'short',
167
+ 'zhongyin_block_buy': False,
168
+ 'expose_m30_nosignal': False,
169
+ 'require_60m_buy_confirm': False, # L44区间套: 60m为日线直接次级别, 买点须经其印证
170
+ # L50 MACD级别选择: 看MACD要选"黄白线和柱子清晰"的级别。30m判据模糊而60m
171
+ # 清晰且60m已印证时, 采信60m的印证结论(避免被模糊级别的噪声误杀好买点)。
172
+ 'l50_macd_level_select': False,
173
+ # ── 卖点区间套(第24/44课): 嵌套顶背驰精确定位 + 布防防卖飞 ──
174
+ # True = S1/S2用"时间嵌套的次级别顶背驰"定位高点; 未嵌套确认时不卖而布防
175
+ # False = 维持旧逻辑(30m要S3 / 宽松末笔向下即卖)
176
+ 'sell_nested_interval': True,
177
+ # L24: 周线上涨+日线盘整背驰的S1/S2 → 不全清, 转布防短差(撤防可骑回趋势)
178
+ 'l24_weekly_uptrend_arm': False, # 实测净亏(好卖点也被转布防), 默认关
179
+ }
180
+
181
+ # ── 速度优化: 次级别K线只取最近N根做缠论分解 ──
182
+ # 第17/44课: 次级别(60m/30m/15m/5m/1m)的职责是"印证当下转折", 其买卖点
183
+ # 只取决于最近的笔/线段/中枢结构; 几年前的分钟级历史对当下印证毫无贡献,
184
+ # 却让每个回测日都对上万根分钟K线重做合并/分型/笔/段, 是最大的耗时点。
185
+ # 截断只作用于次级别印证, 日/周/月线仍用全history(年线、月线大方向不受影响)。
186
+ SUB_TAIL = {'15m': 4000, '5m': 4800, '1m': 2000}
187
+
188
+ def __init__(self, df_daily, df_weekly=None, df_monthly=None, df_30m=None, df_60m=None,
189
+ df_15m=None, df_5m=None, df_1m=None, code='', strict=True):
190
+ self.code = code
191
+ self.strict = strict
192
+ self.df_daily = df_daily.reset_index(drop=True) if not df_daily.empty else df_daily
193
+ self.df_30m = df_30m.reset_index(drop=True) if df_30m is not None and not df_30m.empty else None
194
+ self.df_60m = df_60m.reset_index(drop=True) if df_60m is not None and not df_60m.empty else None
195
+ self.df_15m = df_15m.reset_index(drop=True) if df_15m is not None and not df_15m.empty else None
196
+ self.df_5m = df_5m.reset_index(drop=True) if df_5m is not None and not df_5m.empty else None
197
+ self.df_1m = df_1m.reset_index(drop=True) if df_1m is not None and not df_1m.empty else None
198
+ self.df_weekly = df_weekly.reset_index(drop=True) if df_weekly is not None and not df_weekly.empty else None
199
+ self.df_monthly = df_monthly.reset_index(drop=True) if df_monthly is not None and not df_monthly.empty else None
200
+
201
+ @staticmethod
202
+ def _make_view(level, df, an=None, diagnose=True):
203
+ if an is None:
204
+ if df is None or len(df) < 30:
205
+ return None
206
+ try:
207
+ an = _MAKE_ANALYZER(level, df)
208
+ except Exception:
209
+ return None
210
+ if an is None:
211
+ return None
212
+ sig = an.get_signal()
213
+ zg = an.pivots[-1].zg if an.n_pivots > 0 else None
214
+ zd = an.pivots[-1].zd if an.n_pivots > 0 else None
215
+ return LevelView(level=level, trend=an.trend, n_pivots=an.n_pivots, n_bis=an.n_bis,
216
+ last_bi_dir=an.bis[-1].direction if an.n_bis else '', signal=sig,
217
+ zg=zg, zd=zd, dif=float(an.dif.iloc[-1]) if len(an.dif) else None,
218
+ last_date=pd.Timestamp(df['date'].iloc[-1]),
219
+ diagnostics=(an.diagnose() if diagnose else {}))
220
+
221
+ # ──────────────────────────────────────────────────────────────────
222
+ # 卖点区间套核心: 时间嵌套的次级别顶背驰 (第44课)
223
+ # ──────────────────────────────────────────────────────────────────
224
+ @staticmethod
225
+ def _parent_sell_window(parent_sig):
226
+ """父级(日线)背驰段的时间窗口与顶部价。
227
+ S1: 背驰段C段 = 最后中枢结束 → 当下; 顶 = C段高点 c_high。
228
+ S2: 窗口 = 一卖出现 → 当下(反抽段); 顶 = 一卖高点 prev_high。"""
229
+ ex = (parent_sig.extras or {}) if parent_sig is not None else {}
230
+ if parent_sig is None:
231
+ return '', '', None
232
+ if parent_sig.kind == 'S1':
233
+ w_start = ex.get('pivot_end_date') or ex.get('a_high_date') or ''
234
+ top = ex.get('c_high')
235
+ else: # S2
236
+ w_start = ex.get('s1_date') or ex.get('prev_high_date') or ''
237
+ top = ex.get('prev_high')
238
+ w_end = ex.get('price_date') or ''
239
+ return w_start, w_end, top
240
+
241
+ def _nested_sell_check(self, sub_an, parent_sig, lvl_name, parent_kind):
242
+ """第44课区间套(卖点版): 在父级背驰段时间窗口内找次级别的嵌套顶背驰。
243
+ 优先级: ①嵌套S1(精确高点, 卖在高位)
244
+ ②S3 / 末笔破次级别中枢ZD(转折已确认, 偏晚但必卖)
245
+ ③都没有 → 第24课: 次级别动能未竭, 顶未到 → 不卖(防卖飞), 转布防。
246
+ 返回 (confirmed: bool, note: str)。"""
247
+ w_start, w_end, parent_top = self._parent_sell_window(parent_sig)
248
+
249
+ def in_window(dstr):
250
+ if not w_start or not dstr:
251
+ return True # 信息不足时不因窗口否决(保守放行, 由价位贴近度把关)
252
+ try:
253
+ d = pd.Timestamp(dstr)
254
+ lo = pd.Timestamp(w_start) - pd.Timedelta(days=NESTED_WINDOW_PAD_DAYS)
255
+ hi = (pd.Timestamp(w_end) if w_end else d) + pd.Timedelta(days=1)
256
+ return lo <= d <= hi
257
+ except Exception:
258
+ return True
259
+
260
+ rejected = ''
261
+ # ① 嵌套顶背驰 S1 —— 区间套的本体: 背驰段中套背驰段
262
+ s1 = sub_an.detect_s1()
263
+ if s1 is not None:
264
+ ex2 = s1.extras or {}
265
+ td = ex2.get('c_high_date', '')
266
+ tp = ex2.get('c_high')
267
+ near_top = (parent_top is None or tp is None
268
+ or tp >= parent_top * (1 - NESTED_TOP_PRICE_TOL))
269
+ if in_window(td) and near_top:
270
+ ptxt = f'(顶¥{tp:.3f}@{td})' if tp else ''
271
+ return True, (f'{lvl_name}嵌套顶背驰S1{ptxt}落在日线{parent_kind}背驰段窗口内 '
272
+ f'—— 第44课区间套: 背驰段中套背驰段, 精确定位顶部, 卖在高位区')
273
+ rejected = (f'{lvl_name}虽有S1但【不嵌套】(顶@{td or "?"}不在父级背驰段'
274
+ f'[{w_start}~{w_end}]窗口内, 或低于父级顶{NESTED_TOP_PRICE_TOL:.0%}以上)'
275
+ f' → 属上一波的动能衰竭, 不作本次印证(防卖飞); ')
276
+ # ①b 父级为S2时, 次级别S2(一卖后反抽确认)也可嵌套印证
277
+ if parent_kind == 'S2':
278
+ s2 = sub_an.detect_s2()
279
+ if s2 is not None:
280
+ ex2 = s2.extras or {}
281
+ td = ex2.get('cur_high_date', '')
282
+ if in_window(td):
283
+ return True, (f'{lvl_name}嵌套二卖S2(反抽顶@{td})落在日线S2窗口内 '
284
+ f'—— 第14课: 大级别二卖由次级别相应卖点定位')
285
+ # ② 转折已确认型: S3 / 末笔已破次级别中枢下沿
286
+ s3 = sub_an.detect_s3()
287
+ if s3 is not None:
288
+ return True, (f'{lvl_name}出现三类卖点S3 —— 第44课: 最后一个次级别中枢出现三卖, '
289
+ f'转折已确认(偏晚, 但必须卖)')
290
+ try:
291
+ osc = sub_an.zhongshu_oscillation_monitor()
292
+ if osc.get('alert') and osc.get('direction') == 'down':
293
+ return True, (f'{lvl_name}末笔已离开其最近中枢下沿(第92课向下变盘) '
294
+ f'—— 次级别转折确认, 高位区兑现')
295
+ except Exception:
296
+ pass
297
+ # ③ 次级别上冲动能已竭的当下确认: 末笔向下 + MACD柱已翻负(黄白线收敛下行)。
298
+ # 第24/44课: 区间套要的是"次级别走势的当下转折"; 末笔转下且红柱消失,
299
+ # 说明次级别这一冲已经结束 —— 此时日线背驰卖点立即执行, 不再等更深的破位
300
+ # (这正是旧版"等30m三卖"卖在低位的病根)。只有次级别仍在上冲(末笔向上/
301
+ # 红柱未消)时才转入布防, 防的才是真正的"卖飞"。
302
+ try:
303
+ last_bi = sub_an.bis[-1] if sub_an.n_bis else None
304
+ bar_now = float(sub_an.macd_bar.iloc[-1]) if len(sub_an.macd_bar) else 0.0
305
+ if last_bi is not None and last_bi.direction == 'down' and bar_now < 0:
306
+ return True, (f'{lvl_name}末笔向下且MACD柱已翻负 —— 第24课: 次级别上冲动能已竭, '
307
+ f'当下转折确认, 日线背驰卖点立即执行(不等{lvl_name}跌出三卖)')
308
+ except Exception:
309
+ pass
310
+ return False, (rejected + f'{lvl_name}动能未竭(末笔仍向上/MACD红柱未消): 无嵌套顶背驰/无S3 '
311
+ f'→ 第24课: 次级别未转折, 大级别顶未到 → 暂不卖(防卖飞), 转入区间套布防')
312
+
313
+ def _confirm_30m_for_buy(self, m30, parent_kind=''):
314
+ if m30 is None or m30.n_bis < 5:
315
+ return False, '30分钟笔数不足(<5), 无法做次级别精确印证'
316
+ if parent_kind == 'B2':
317
+ if m30.detect_b1() is not None:
318
+ return True, '30分钟出现一类买点B1 —— 第14课: 大级别第二类买点由次一级别相应走势的一类买点构成'
319
+ if m30.detect_b2() is not None:
320
+ return True, '30分钟出现二类买点B2 —— 次级别一买后的回试确认, 属延后确认, 精度低于B1'
321
+ return False, '30分钟未出现B1/B2 —— 大级别二买缺少次级别买点确认'
322
+ for fn, name in ((m30.detect_b1,'B1'),(m30.detect_b3,'B3'),(m30.detect_b2,'B2')):
323
+ if fn() is not None:
324
+ return True, f'30分钟出现标准买点{name} —— 第44课: 次级别走势确认转折, 日线买点成立'
325
+ if self.strict:
326
+ return False, '30分钟未出现任何标准买点(B1/B2/B3) —— 严格模式: 第44课次级别印证未满足, 日线买点暂不成立'
327
+ if m30.bis[-1].direction == 'up':
328
+ return True, '30分钟最后一笔向上, 次级别已启动(宽松确认)'
329
+ return False, '30分钟最后一笔仍向下且无买点, 次级别未确认转折'
330
+
331
+ def _confirm_30m_for_sell(self, m30, parent_kind='', parent_sig=None):
332
+ if m30 is None or m30.n_bis < 5:
333
+ return False, '30分钟笔数不足(<5), 无法做次级别精确印证'
334
+ # ── 新: 卖点区间套(第24/44课) —— S1/S2用嵌套顶背驰定位高点 ──
335
+ if (self.CFG.get('sell_nested_interval') and parent_sig is not None
336
+ and parent_kind in ('S1', 'S2')):
337
+ return self._nested_sell_check(m30, parent_sig, '30分钟', parent_kind)
338
+ # ── 旧逻辑(S3父级 / 开关关闭时) ──
339
+ if parent_kind == 'S2':
340
+ if m30.detect_s1() is not None:
341
+ return True, '30分钟出现一类卖点S1 —— 大级别第二类卖点由次一级别相应走势的一类卖点精确定位'
342
+ if m30.detect_s2() is not None:
343
+ return True, '30分钟出现二类卖点S2 —— 次级别一卖后的反抽确认, 属延后确认, 精度低于S1'
344
+ return False, '30分钟未出现S1/S2 —— 大级别二卖缺少次级别卖点确认'
345
+ if m30.detect_s3() is not None:
346
+ return True, '30分钟出现三类卖点S3 —— 严格满足第44课"小背驰-大转折定理"的必要条件(最后一个次级别中枢出现三卖)'
347
+ if self.strict:
348
+ return False, '30分钟未出现三类卖点S3 —— 严格模式: 第44课明确要求"最后一个次级别中枢出现三卖", 必要条件未满足, 日线卖点不构成大级别转折'
349
+ if m30.detect_s1() is not None:
350
+ return True, '30分钟出现一类卖点(顶背驰), 次级别转折确认(宽松)'
351
+ if m30.bis[-1].direction == 'down':
352
+ return True, '30分钟最后一笔向下, 次级别已转弱(宽松确认)'
353
+ return False, '30分钟未出现卖点且最后一笔仍向上, 次级别未确认转折'
354
+
355
+ def _confirm_finer(self, an, is_buy, lvl_name, parent_name, parent_kind='', parent_sig=None):
356
+ if an is None or an.n_bis < 5:
357
+ return False, f'{lvl_name}笔数不足(<5), 无法做次级别精确印证'
358
+ if is_buy:
359
+ if parent_kind == 'B2':
360
+ if an.detect_b1() is not None:
361
+ return True, f'{lvl_name}出现B1 —— 第14课: {parent_name}二买由次一级别一买构成'
362
+ if an.detect_b2() is not None:
363
+ return True, f'{lvl_name}出现B2 —— 次级别一买后的回试确认, 属延后确认, 精度低于B1'
364
+ return False, f'{lvl_name}未出现B1/B2 —— {parent_name}二买缺少次级别买点确认'
365
+ for fn, name in ((an.detect_b1,'B1'),(an.detect_b3,'B3'),(an.detect_b2,'B2')):
366
+ if fn() is not None:
367
+ return True, f'{lvl_name}出现标准买点{name} —— 第44课逐级处理: {lvl_name}走势确认{parent_name}的转折'
368
+ if self.strict:
369
+ return False, f'{lvl_name}未出现标准买点(B1/B2/B3) —— 严格模式: 末级印证未满足, 信号降级'
370
+ if an.bis[-1].direction == 'up':
371
+ return True, f'{lvl_name}最后一笔向上, 次级别已启动(宽松确认)'
372
+ return False, f'{lvl_name}最后一笔仍向下且无买点, 末级未确认, 信号降级'
373
+ else:
374
+ # ── 新: 卖点区间套向更细级别逐级传递(同一父级背驰段窗口) ──
375
+ if (self.CFG.get('sell_nested_interval') and parent_sig is not None
376
+ and parent_kind in ('S1', 'S2')):
377
+ ok, note = self._nested_sell_check(an, parent_sig, lvl_name, parent_kind)
378
+ if ok:
379
+ return True, note + f' —— 第44课逐级处理: {lvl_name}确认{parent_name}的转折'
380
+ return False, note
381
+ if parent_kind == 'S2':
382
+ if an.detect_s1() is not None:
383
+ return True, f'{lvl_name}出现S1 —— {parent_name}二卖由次一级别一卖精确定位'
384
+ if an.detect_s2() is not None:
385
+ return True, f'{lvl_name}出现S2 —— 次级别一卖后的反抽确认, 属延后确认, 精度低于S1'
386
+ return False, f'{lvl_name}未出现S1/S2 —— {parent_name}二卖缺少次级别卖点确认'
387
+ if an.detect_s3() is not None:
388
+ return True, f'{lvl_name}出现三类卖点S3 —— 第44课逐级处理: {lvl_name}走势确认{parent_name}的转折'
389
+ if self.strict:
390
+ return False, f'{lvl_name}未出现三类卖点S3 —— 严格模式: 末级印证未满足, 信号降级'
391
+ if an.detect_s1() is not None:
392
+ return True, f'{lvl_name}出现一类卖点(顶背驰), 次级别转折确认(宽松)'
393
+ if an.bis[-1].direction == 'down':
394
+ return True, f'{lvl_name}最后一笔向下, 次级别已转弱(宽松确认)'
395
+ return False, f'{lvl_name}未出现卖点且最后一笔仍向上, 末级未确认, 信号降级'
396
+
397
+ def _confirm_5m(self, m5, is_buy, parent_kind='', parent_sig=None):
398
+ return self._confirm_finer(m5, is_buy, '5分钟', '30分钟', parent_kind, parent_sig)
399
+
400
+ def _confirm_1m(self, m1, is_buy, parent_kind='', parent_sig=None):
401
+ return self._confirm_finer(m1, is_buy, '1分钟', '5分钟', parent_kind, parent_sig)
402
+
403
+ def analyze(self, analysis_date=None, positions=None):
404
+ if self.df_daily.empty or len(self.df_daily) < 30:
405
+ return None
406
+ df_d = self.df_daily
407
+ if analysis_date is not None:
408
+ analysis_date = pd.Timestamp(analysis_date)
409
+ df_d = df_d[df_d['date'] <= analysis_date].reset_index(drop=True)
410
+ if len(df_d) < 30:
411
+ return None
412
+ last_date = pd.Timestamp(df_d['date'].iloc[-1])
413
+ cur_price = float(df_d['close'].iloc[-1])
414
+
415
+ if self.df_weekly is not None:
416
+ df_w = self.df_weekly[self.df_weekly['date'] <= last_date].reset_index(drop=True)
417
+ else:
418
+ df_w = resample_weekly(df_d)
419
+ if self.df_monthly is not None:
420
+ df_mo = self.df_monthly[self.df_monthly['date'] <= last_date].reset_index(drop=True)
421
+ else:
422
+ df_mo = resample_monthly(df_d)
423
+ _tail = self.SUB_TAIL or {}
424
+ def _cut_sub(df, lvl):
425
+ if df is None:
426
+ return None
427
+ sub = df[df['date'] <= last_date + pd.Timedelta(days=1)]
428
+ n = _tail.get(lvl, 0)
429
+ if n and len(sub) > n:
430
+ sub = sub.tail(n)
431
+ return sub.reset_index(drop=True)
432
+ df_6 = _cut_sub(self.df_60m, '60m')
433
+ df_m = _cut_sub(self.df_30m, '30m')
434
+ df_15 = _cut_sub(self.df_15m, '15m')
435
+ df_5 = _cut_sub(self.df_5m, '5m')
436
+ df_1 = _cut_sub(self.df_1m, '1m')
437
+
438
+ wv = self._make_view('weekly', df_w, diagnose=False)
439
+ mov = self._make_view('monthly', df_mo, diagnose=False) if self.CFG.get('use_monthly_gate') else None
440
+ daily_an = None
441
+ try:
442
+ if len(df_d) >= 30:
443
+ daily_an = _MAKE_ANALYZER('daily', df_d)
444
+ except Exception:
445
+ daily_an = None
446
+ dv = self._make_view('daily', df_d, an=daily_an, diagnose=False) if daily_an is not None \
447
+ else self._make_view('daily', df_d, diagnose=False)
448
+
449
+ m60_an = None
450
+ m60_built = False
451
+ def _get_m60():
452
+ nonlocal m60_an, m60_built
453
+ if not m60_built:
454
+ m60_built = True
455
+ if df_6 is not None and len(df_6) >= 30:
456
+ try: m60_an = _MAKE_ANALYZER('60m', df_6)
457
+ except Exception: m60_an = None
458
+ return m60_an
459
+
460
+ m30_an = None
461
+ m30_built = False
462
+ def _get_m30():
463
+ nonlocal m30_an, m30_built
464
+ if not m30_built:
465
+ m30_built = True
466
+ if df_m is not None and len(df_m) >= 30:
467
+ try: m30_an = _MAKE_ANALYZER('30m', df_m)
468
+ except Exception: m30_an = None
469
+ return m30_an
470
+
471
+ def _mv():
472
+ an = _get_m30()
473
+ if an is None:
474
+ return None
475
+ return self._make_view('30m', df_m, an=an, diagnose=False)
476
+
477
+ diagnostics = daily_an.diagnose() if daily_an is not None else {}
478
+
479
+ chain = []
480
+ yearline_dir = 'unknown'; yearline_val = None
481
+ if len(df_d) >= 60:
482
+ _n = min(250, len(df_d))
483
+ yearline_val = float(df_d['close'].tail(_n).mean()) if _n >= 20 else None
484
+ _ma = df_d['close'].rolling(min(250, len(df_d)), min_periods=20).mean()
485
+ if len(_ma) and not pd.isna(_ma.iloc[-1]):
486
+ yearline_val = float(_ma.iloc[-1])
487
+ if yearline_val is not None:
488
+ above = cur_price >= yearline_val
489
+ yearline_dir = 'above' if above else 'below'
490
+ ytxt = (f'现价¥{cur_price:.3f} {"≥" if above else "<"} 年线MA250 ¥{yearline_val:.3f} '
491
+ f'→ {"站上年线, 长线可做多" if above else "年线下方, 第106课不做多(只看反弹/卖点)"}')
492
+ chain.append(('年线', ytxt,
493
+ '第7/106课: 年线(MA250)是长线生命线; 站上才考虑做多, 跌破年线长线转空'))
494
+ else:
495
+ chain.append(('年线', '日线历史不足, 年线MA250 暂不可用', '第7/106课'))
496
+
497
+ monthly_dir = 'unknown'
498
+ if mov is not None:
499
+ monthly_dir = mov.trend
500
+ chain.append(('monthly', f'{monthly_dir} (月线定大方向: L69 月线看最实质大方向)',
501
+ '第69/108课: 月线分型/笔/线段确定中期大方向; 底部=第一类买点到中枢首次走出三买前'))
502
+ if self.CFG.get('monthly_ma_filter') and len(df_mo) >= 5:
503
+ ma5_m = float(df_mo['close'].tail(5).mean())
504
+ if cur_price < ma5_m and monthly_dir != 'down_trend':
505
+ monthly_dir = 'down_trend'
506
+ chain.append(('monthly', f'价({cur_price:.2f})在5月线({ma5_m:.2f})下 → 大方向按偏空处理',
507
+ '第106课: 5月线是长线的关键, 牛市第一轮调整不跌破5月线'))
508
+
509
+ if wv is None:
510
+ chain.append(('weekly', '周线数据不足(<30根), 方向未知', ''))
511
+ weekly_dir = 'unknown'
512
+ else:
513
+ weekly_dir = wv.trend
514
+ dir_txt = {'up_trend':'上涨趋势 → 日线只接受【买点】','down_trend':'下跌趋势 → 日线只接受【卖点】/观望',
515
+ 'consolidation':'盘整 → 日线买卖点都看,但降级'}.get(weekly_dir, weekly_dir)
516
+ chain.append(('weekly', f'{weekly_dir} {dir_txt}',
517
+ '第43课: 大级别走势类型限定小级别的操作方向; 不允许"上涨+上涨""下跌+下跌"'))
518
+
519
+ if dv is None:
520
+ return None
521
+ daily_sig = dv.signal
522
+ if daily_sig is None:
523
+ chain.append(('daily', f'{dv.trend}, 无买卖点信号', ''))
524
+ else:
525
+ chain.append(('daily', f'出现 {daily_sig.kind}: {daily_sig.reason[:400]}', '第21课: 买卖点完备性定理'))
526
+
527
+ if daily_sig is None:
528
+ action, conf, note = self._no_signal_decision(wv, dv, cur_price)
529
+ m30_ns = _mv() if self.CFG.get('expose_m30_nosignal') else None
530
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=m30_ns,
531
+ action=action, confidence=conf, final_kind='', cur_price=cur_price, chain=chain, note=note,
532
+ diagnostics=diagnostics, monthly=mov)
533
+
534
+ is_buy = daily_sig.kind in ('B1','B2','B3')
535
+ is_sell = daily_sig.kind in ('S1','S2','S3')
536
+
537
+ if self.CFG.get('zhongyin_block_buy') and is_buy and daily_sig.kind != 'B3' and daily_an is not None:
538
+ zy = daily_an.in_zhongyin()
539
+ if zy.get('in_zhongyin'):
540
+ chain.append(('中阴', f'第88-90课: 当前处中阴(方向未定+BOLL收口) → 暂不开新仓: {zy["reason"][:120]}', '第89课: ��阴阶段方向未定, 不宜开新仓'))
541
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=None,
542
+ action='WATCH', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
543
+ chain=chain, blocked_reason='中阴方向未定', note='中阴阶段暂不开仓(L88-90)',
544
+ diagnostics=diagnostics, monthly=mov)
545
+
546
+ if positions:
547
+ fail_kinds = []
548
+ for bk, pos in positions.items():
549
+ stop = pos.get('stop_px')
550
+ if stop is not None and cur_price < stop:
551
+ fail_kinds.append(bk)
552
+ if fail_kinds:
553
+ chain.append(('证伪', f'持仓{"/".join(fail_kinds)}跌破建仓日锁定止损线 → 清仓', '第13/20课: 买点结构被破坏即证伪'))
554
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
555
+ action='SELL', confidence='HIGH', final_kind='STOP', cur_price=cur_price,
556
+ chain=chain, note='结构证伪止损, 次日清仓',
557
+ diagnostics=diagnostics, monthly=mov)
558
+
559
+ blocked = ''
560
+ downgrade = False
561
+ if weekly_dir == 'up_trend' and is_sell:
562
+ downgrade = True
563
+ chain.append(('联立','周线上涨 + 日线卖点 → 第44课: 小级别(日线)顶背驰未必引发大级别(周线)转折, 卖点降级为"减仓/短差"',
564
+ '第24课: 日线背驰除非周线同时背驰,否则不制造周线大顶'))
565
+ elif weekly_dir == 'down_trend' and is_buy:
566
+ if daily_sig.kind in ('B1','B2'):
567
+ chain.append(('联立','周线下跌 + 日线一/二买 → 下跌趋势的转折尝试, 必须由30分钟严格印证','第29课: 下跌的转折=上涨或盘整, 由背驰导致'))
568
+ else:
569
+ blocked = '周线下跌趋势中出现日线三买 —— 第43课: 三买属上涨结构, 与周线下跌矛盾, 大概率是下跌中继的假三买'
570
+ elif monthly_dir == 'down_trend' and is_buy and daily_sig.kind == 'B3' and not blocked:
571
+ blocked = '月线下跌大方向中出现日线三买 —— 第43/69课: 三买属上涨结构, 与月线大方向矛盾, 大概率假三买'
572
+ elif weekly_dir == 'up_trend' and is_buy:
573
+ chain.append(('联立','周线上涨 + 日线买点 → 方向一致, 进入30分钟精确定位','第43课: 大小级别同向, 操作最顺'))
574
+ elif weekly_dir == 'down_trend' and is_sell:
575
+ chain.append(('联立','周线下跌 + 日线卖点 → 方向一致, 趋势性卖出','第43课: 大小级别同向'))
576
+ elif weekly_dir == 'consolidation':
577
+ downgrade = True
578
+ chain.append(('联立','周线盘整 → 日线信号有效但降级(盘整中买卖点力度弱)','第29课: 盘整中的转折力度弱于趋势'))
579
+
580
+ # ── 周线二买 → 日线一买精确定位锚 (第14/17课) ──
581
+ if is_buy and wv is not None and wv.signal is not None \
582
+ and getattr(wv.signal, 'kind', '') == 'B2':
583
+ _dex = (daily_sig.extras or {}) if daily_sig is not None else {}
584
+ _anchor = _dex.get('b1_price') or _dex.get('cur_low')
585
+ if _anchor:
586
+ daily_sig.extras = dict(_dex)
587
+ daily_sig.extras['weekly_b2_daily_b1_anchor'] = float(_anchor)
588
+ chain.append(('联立',
589
+ f'✓ 周线二买 + 日线买点共振: 周线B2由日线一买构成(第14课), '
590
+ f'定位锚=日线一买位¥{float(_anchor):.3f}, 跌破该锚则周线二买证伪',
591
+ '第17课: 大级别买点的精确定位要落到次级别的具体买点价位'))
592
+ else:
593
+ chain.append(('联立',
594
+ '周线二买成立但日线尚无可定位的一买锚 → 等日线给出一买价位再重仓',
595
+ '第14课'))
596
+
597
+ if blocked:
598
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
599
+ action='WATCH', confidence='NONE', final_kind=daily_sig.kind, cur_price=cur_price,
600
+ chain=chain, blocked_reason=blocked, note='周线方向闸门拦截, 不操作',
601
+ diagnostics=diagnostics, monthly=mov)
602
+
603
+ m60_an = _get_m60()
604
+ ok60 = False
605
+ if m60_an is not None:
606
+ if is_buy:
607
+ ok60, note60 = self._confirm_finer(m60_an, True, '60分钟', '日线', daily_sig.kind)
608
+ else:
609
+ ok60, note60 = self._confirm_finer(m60_an, False, '60分钟', '日线', daily_sig.kind,
610
+ parent_sig=daily_sig)
611
+ chain.append(('60m', ('✓ 次级别确认: ' if ok60 else '✗ 次级别未确认(降级): ')+note60,
612
+ '第44课: 区间套 —— 日线的转折先由直接次级别60分钟走势确认'))
613
+ if not ok60:
614
+ downgrade = True
615
+ if self.CFG.get('require_60m_buy_confirm') and is_buy and daily_sig.kind in ('B1', 'B2'):
616
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
617
+ action='WATCH', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
618
+ chain=chain, blocked_reason='60分钟直接次级别未印证买点(L44区间套)',
619
+ note=f'日线{daily_sig.kind}买点但60m未现B1/B2 → 等直接次级别印证再动手',
620
+ diagnostics=diagnostics, monthly=mov)
621
+ else:
622
+ if df_6 is not None:
623
+ chain.append(('60m', '60分钟数据不足(<30根) → 跳过该级印证', '第44课: 区间套逐级确认'))
624
+
625
+ m30_an = _get_m30()
626
+ if m30_an is None:
627
+ chain.append(('30m','无30分钟数据 → 缺少次级别精确印证, 信号降级','第44课: 操作级别的买卖点需次级别走势确认'))
628
+ confirmed = False; confirm_note = '缺30分钟数据'; downgrade = True
629
+ else:
630
+ if is_buy:
631
+ confirmed, confirm_note = self._confirm_30m_for_buy(m30_an, daily_sig.kind)
632
+ else:
633
+ confirmed, confirm_note = self._confirm_30m_for_sell(m30_an, daily_sig.kind,
634
+ parent_sig=daily_sig)
635
+ chain.append(('30m', ('✓ 次级别确认: ' if confirmed else '✗ 次级别未确认: ')+confirm_note, '第44课: 小背驰-大转折定理(必要条件)'))
636
+
637
+ # ── 60m嵌套印证补位 (第44课区间套, 卖点版) ──
638
+ # 30m动能未竭但60m(日线的直接次级别)已给出嵌套顶背驰/转折确认 → 采信60m。
639
+ # 日线的"次级别"本就是60m; 30m是60m的次级别, 不应让更细级别一票否决直接次级别。
640
+ if (self.CFG.get('sell_nested_interval') and is_sell and not confirmed
641
+ and ok60 and daily_sig.kind in ('S1', 'S2')):
642
+ confirmed = True
643
+ confirm_note = '30m动能未竭, 但60m(日线直接次级别)已嵌套印证 → 采信60m(第44课: 逐级处理, 直接次级别优先)'
644
+ chain.append(('联立', '✓ ' + confirm_note, '第44课区间套'))
645
+
646
+ # ── L50 MACD级别选择: 选黄白线和柱子清晰的级别看MACD ──
647
+ if (self.CFG.get('l50_macd_level_select') and is_buy and not confirmed
648
+ and ok60 and m60_an is not None and m30_an is not None):
649
+ try:
650
+ c60 = m60_an.macd_clarity(); c30 = m30_an.macd_clarity()
651
+ if c30['score'] < 0.4 and c60['score'] >= max(0.4, c30['score'] * 1.5):
652
+ confirmed = True
653
+ chain.append(('联立',
654
+ f"✓ L50 MACD级别选择: 30m MACD{c30['label']}(清晰度{c30['score']}) 判据不可靠; "
655
+ f"60m MACD{c60['label']}(清晰度{c60['score']})且60m已印证买点 → 采信60m结论",
656
+ '第50课: 看MACD要选黄白线和柱子走势清晰的级别'))
657
+ except Exception:
658
+ pass
659
+
660
+ m15_an = None; m15_confirmed = None
661
+ if confirmed and df_15 is not None and len(df_15) >= 30:
662
+ try: m15_an = _MAKE_ANALYZER('15m', df_15)
663
+ except Exception: m15_an = None
664
+ if not confirmed:
665
+ pass
666
+ elif df_15 is None:
667
+ pass
668
+ elif m15_an is None:
669
+ chain.append(('15m','15分钟数据不足(<30根) → 跳过该级印证','第44课: 区间套逐级确认'))
670
+ else:
671
+ ok15, note15 = self._confirm_finer(m15_an, is_buy, '15分钟', '30分钟', daily_sig.kind,
672
+ parent_sig=(daily_sig if is_sell else None))
673
+ m15_confirmed = ok15
674
+ chain.append(('15m', ('✓ 次级别确认: ' if ok15 else '✗ 次级别未确认(降级,不拦截): ')+note15,
675
+ '第44课: 逐级处理 —— 30分钟的转折由15分钟走势确认'))
676
+ if not ok15: downgrade = True
677
+
678
+ m5_an = None; m5_confirmed = None
679
+ if confirmed and df_5 is not None and len(df_5) >= 30:
680
+ try: m5_an = _MAKE_ANALYZER('5m', df_5)
681
+ except Exception: m5_an = None
682
+ if not confirmed:
683
+ pass
684
+ elif df_5 is None:
685
+ chain.append(('5m','无5分钟数据 → 缺该级逐级印证, 信号降级','第44课: 15分钟的转折需5分钟走势逐级确认')); downgrade = True
686
+ elif m5_an is None:
687
+ chain.append(('5m','5分钟数据��足(<30根) → 缺该级印证, 信号降级','第44课: 15分钟的转折需5分钟走势逐级确认')); downgrade = True
688
+ else:
689
+ ok5, note5 = self._confirm_5m(m5_an, is_buy, daily_sig.kind,
690
+ parent_sig=(daily_sig if is_sell else None))
691
+ m5_confirmed = ok5
692
+ chain.append(('5m', ('✓ 次级别确认: ' if ok5 else '✗ 次级别未确认(降级,不拦截): ')+note5, '第44课: 逐级处理 —— 15分钟的转折由5分钟走势确认'))
693
+ if not ok5: downgrade = True
694
+
695
+ m1_an = None; m1_confirmed = None
696
+ do_1m = confirmed and (m5_confirmed is True)
697
+ if do_1m and df_1 is not None and len(df_1) >= 30:
698
+ try: m1_an = _MAKE_ANALYZER('1m', df_1)
699
+ except Exception: m1_an = None
700
+ if not do_1m:
701
+ pass
702
+ elif df_1 is None:
703
+ chain.append(('1m','无1分钟数据 → 缺区间套最末级印证, 信号降级','第44课: 区间套 —— 5分钟的转折需1分钟走势逐级确认')); downgrade = True
704
+ elif m1_an is None:
705
+ chain.append(('1m','1分钟数据不足(<30根) → 缺最末级印证, 信号降级','第44课: 区间套 —— 5分钟的转折需1分钟走势逐级确认')); downgrade = True
706
+ else:
707
+ ok1, note1 = self._confirm_1m(m1_an, is_buy, daily_sig.kind,
708
+ parent_sig=(daily_sig if is_sell else None))
709
+ m1_confirmed = ok1
710
+ chain.append(('1m', ('✓ 末级确认: ' if ok1 else '✗ 末级未确认(降级,不拦截): ')+note1, '第44课: 区间套 —— 5分钟的转折由1分钟走势确认'))
711
+ if not ok1: downgrade = True
712
+
713
+ # ── L24规则: 周线上涨 + 日线【盘整背驰】(非趋势背驰)的S1/S2 ──
714
+ # 第24课: 某级别的背驰导致该级别的转折; 日线背驰若周线未同步背驰,
715
+ # 只构成日线级别的调整, 不是大顶 → 不全清仓, 转入区间套布防做短差:
716
+ # 真转折由布防线(嵌套背驰/破30m中枢ZD/峰值回落)兜底, 趋势延续则由
717
+ # 撤防机制(强势新高消化背驰, 第26课)继续骑中枢上移(第91课①)。
718
+ if (self.CFG.get('sell_nested_interval') and self.CFG.get('l24_weekly_uptrend_arm')
719
+ and is_sell and daily_sig.kind in ('S1', 'S2') and weekly_dir == 'up_trend'):
720
+ _dg = getattr(daily_sig, 'diverge_grade', None)
721
+ if _dg is not None and not getattr(_dg, 'is_trend_divergence', False):
722
+ _arm_zd = (m30_an.pivots[-1].zd if (m30_an is not None and m30_an.n_pivots) else None)
723
+ _ex = daily_sig.extras or {}
724
+ _arm_high = _ex.get('c_high') or _ex.get('prev_high') or cur_price
725
+ chain.append(('L24', f'周线上涨 + 日线{daily_sig.kind}仅为盘整背驰(非趋势背驰) '
726
+ f'→ 第24课: 不构成周线级别大顶, 不全清 → 转区间套布防短差'
727
+ f'(布防线: 30m中枢ZD{f"¥{_arm_zd:.3f}" if _arm_zd else "暂无"}/'
728
+ f'峰值回落{SELL_ARM_PEAK_DROP:.0%}; 强势新高则撤防骑趋势)',
729
+ '第24课: 日线背驰除非周线同步背驰, 否则只造成日线级别调整'))
730
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
731
+ action='HOLD', confidence='MEDIUM', final_kind=daily_sig.kind,
732
+ cur_price=cur_price, chain=chain,
733
+ note=f'L24: 周线上涨中的日线盘整背驰{daily_sig.kind}, 布防短差不全清',
734
+ sell_armed=True, arm_zd=_arm_zd,
735
+ arm_high=float(_arm_high) if _arm_high else None,
736
+ diagnostics=diagnostics, monthly=mov)
737
+
738
+ action = 'BUY' if is_buy else 'SELL'
739
+ if self.CFG.get('mode') == 'long' and is_sell and daily_sig.kind in ('S1', 'S2'):
740
+ big_up_long = ((wv is not None and wv.trend == 'up_trend') or
741
+ (mov is not None and monthly_dir == 'up_trend'))
742
+ ma5m_ok = True
743
+ if len(df_mo) >= 5:
744
+ ma5m = float(df_mo['close'].tail(5).mean())
745
+ ma5m_ok = cur_price >= ma5m
746
+ if big_up_long and ma5m_ok:
747
+ chain.append(('mode', f'长线模式: 日线{daily_sig.kind}非周线级别转折且大方向上涨 → 持有(操作级别=周线)', '第72课: 看周线则日线调整不构成卖段; 第61课大级别不因小级别震荡卖'))
748
+ # 长线模式持有也挂上布防线(第44课): 真转折时不至于全程坐滑梯
749
+ _arm_zd = (m30_an.pivots[-1].zd if (m30_an is not None and m30_an.n_pivots) else None)
750
+ _ex = daily_sig.extras or {}
751
+ _arm_high = _ex.get('c_high') or _ex.get('prev_high') or cur_price
752
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
753
+ action='HOLD', confidence='MEDIUM', final_kind=daily_sig.kind, cur_price=cur_price,
754
+ chain=chain, note=f'长线模式持有: 日线{daily_sig.kind}属次级别调整, 周线方向未转',
755
+ sell_armed=bool(self.CFG.get('sell_nested_interval')),
756
+ arm_zd=_arm_zd, arm_high=float(_arm_high) if _arm_high else None,
757
+ diagnostics=diagnostics, monthly=mov)
758
+ if not confirmed:
759
+ if is_sell:
760
+ # ── 卖点区间套布防 (第24/44课): 背驰已现但次级别动能未竭 ──
761
+ # 不立即卖(防卖飞), 也绝不傻等到三卖(防坐滑梯): 持仓转入布防,
762
+ # 由回测端的布防线(嵌套背驰出现/破30m中枢ZD/峰值回落阈值)收割。
763
+ # 布防仅用于S1的精确定顶。S2是一卖后的【反抽】卖点(第15课):
764
+ # 上方空间被一卖高点封死, 反抽本身就是用来卖的, "等次级别动能衰竭"
765
+ # 与其性质矛盾 → S2未印证时维持"先卖再说"(走下方LOW置信卖出)。
766
+ if (self.CFG.get('sell_nested_interval') and daily_sig.kind == 'S1'):
767
+ arm_zd = (m30_an.pivots[-1].zd if (m30_an is not None and m30_an.n_pivots) else None)
768
+ ex = daily_sig.extras or {}
769
+ arm_high = ex.get('c_high') or ex.get('prev_high') or cur_price
770
+ chain.append(('卖点布防',
771
+ f'日线{daily_sig.kind}背驰已现但次级别动能未竭 → 不立即卖(防卖飞), '
772
+ f'转入区间套布防: ①次级别出现嵌套顶背驰即卖 '
773
+ f'②跌破30m最近中枢下沿ZD{f"¥{arm_zd:.3f}" if arm_zd else "(暂无30m中枢)"}即卖 '
774
+ f'③较布防后峰值回落{SELL_ARM_PEAK_DROP:.0%}即卖',
775
+ '第44课区间套: 背驰段中套背驰段定精确高点; 第24课: 次级别未背驰, 大级别顶未到'))
776
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
777
+ action='HOLD', confidence='MEDIUM', final_kind=daily_sig.kind,
778
+ cur_price=cur_price, chain=chain,
779
+ note=f'区间套布防中: 日线{daily_sig.kind}背驰段内, 等次级别嵌套背驰收敛后高位兑现(第44课)',
780
+ sell_armed=True, arm_zd=arm_zd,
781
+ arm_high=float(arm_high) if arm_high else None,
782
+ confidence_reasons=[f'布防原因: {confirm_note}'],
783
+ diagnostics=diagnostics, monthly=mov)
784
+ big_up_sell = ((wv is not None and wv.trend == 'up_trend') or
785
+ (mov is not None and monthly_dir == 'up_trend'))
786
+ if self.CFG.get('require_sublevel_sell_confirm') and big_up_sell:
787
+ chain.append(('sell_gate', f'日线{daily_sig.kind}卖点但次级别未印证, 且大方向上涨 → 持有(L61: 大级别不因小级别震荡卖)', '第61课: 区间套确认; 大级别操作不因小级别震荡清仓'))
788
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
789
+ action='HOLD', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
790
+ chain=chain, note=f'日线{daily_sig.kind}卖点次级别未印证+大方向上涨, 持有等确认: {confirm_note}',
791
+ confidence_reasons=[f'次级别未印证持有: {confirm_note}'],
792
+ diagnostics=diagnostics, monthly=mov)
793
+ chain.append(('sell_gate',f'日线{daily_sig.kind}卖点已成立, 次级别未印证只降级不拦截','第14/15/21课: 买点买、卖点卖; 区间套用于精确定位, 不是否决卖点'))
794
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
795
+ action='SELL', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
796
+ chain=chain, note=f'日线{daily_sig.kind}卖点先执行; 次级别未印证仅降级: {confirm_note}',
797
+ confidence_reasons=[f'次级别未印证: {confirm_note}'],
798
+ diagnostics=diagnostics, monthly=mov)
799
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
800
+ action='WATCH', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
801
+ chain=chain, blocked_reason=f'次级别未印证({confirm_note})',
802
+ note='日线有买/非S1卖信号但次级别未确认 -- 等次级别出信号再动手',
803
+ diagnostics=diagnostics, monthly=mov)
804
+
805
+ score = 100; conf_reasons = []
806
+ def _penalty(pts, reason):
807
+ nonlocal score
808
+ score -= pts; conf_reasons.append(f'(-{pts}) {reason}')
809
+ if mov is not None and monthly_dir == 'down_trend' and is_buy:
810
+ _penalty(25, '第69课: 月线大方向下跌, 日线买点属逆大方向的转折尝试')
811
+ trend_piv_lt2 = (daily_an is not None and daily_an.n_trend_pivots < 2)
812
+ if trend_piv_lt2 and daily_sig.kind in ('B1', 'S1'):
813
+ _penalty(20, f'第27课: 背驰段前仅{daily_an.n_trend_pivots}个中枢(<2), 盘整背驰非趋势背驰')
814
+ if daily_an is not None and daily_an.trend == 'consolidation':
815
+ _penalty(15, '第29课: 日线处盘整结构, 盘整中转折力度弱')
816
+ daily_dg = getattr(daily_sig, 'diverge_grade', None)
817
+ if daily_dg is not None and daily_dg.grade == 'WEAK':
818
+ trig = '面积' if daily_dg.area_ok else 'DIF极值'
819
+ _penalty(15, f'第27课: 背驰仅满足{trig}单一判据(WEAK), 非标准背驰')
820
+ if (daily_dg is not None and daily_dg.grade in ('STRONG', 'WEAK')
821
+ and not daily_dg.is_trend_divergence and not trend_piv_lt2
822
+ and daily_sig.kind in ('B1', 'S1')):
823
+ _penalty(10, '第27课: 背驰段为盘整背驰(非趋势背驰), 力度偏弱')
824
+ if downgrade:
825
+ _penalty(12, '第44课: 区间套次级别未完全印证(精度降级, 非证伪)')
826
+ if daily_sig.kind in ('B2', 'S2'):
827
+ has_delayed = any(lvl in ('30m', '15m', '5m', '1m') and '延后确认' in concl
828
+ for lvl, concl, _ in chain)
829
+ if has_delayed:
830
+ _penalty(8, '二/二卖由次级别延后确认(非次级别一买/一卖精确定位), 精度略降')
831
+ ex = daily_sig.extras or {}
832
+ if daily_sig.kind == 'B3':
833
+ missing = []
834
+ if ex.get('b1_price') is None:
835
+ missing.append('一买')
836
+ if ex.get('b2_price') is None:
837
+ missing.append('二买')
838
+ if missing:
839
+ _penalty(6, '第20/21课: 三买未能定位对应' + '/'.join(missing) + ', 质量略降')
840
+ if ex.get('late_trend_b3'):
841
+ _penalty(12, '第20/92课: 第二个以上同向中枢后的三买, 操作意义下降')
842
+ if daily_sig.kind == 'S3':
843
+ missing = []
844
+ if ex.get('s1_price') is None:
845
+ missing.append('一卖')
846
+ if ex.get('s2_price') is None:
847
+ missing.append('二卖')
848
+ if missing:
849
+ _penalty(6, '第20/21课: 三卖未能定位对应' + '/'.join(missing) + ', 质量略降')
850
+ if daily_dg is not None and daily_dg.grade == 'STRONG' and daily_dg.is_trend_divergence:
851
+ score += 10; conf_reasons.append('(+10) 第27课: 标准趋势背驰(>=2中枢+面积+DIF), 高质量')
852
+ # ── 卖点区间套加分: 30m嵌套顶背驰精确定位(卖在高位区) ──
853
+ if is_sell and any(lvl == '30m' and '嵌套顶背驰' in concl for lvl, concl, _ in chain):
854
+ score += 8; conf_reasons.append('(+8) 第44课: 30m嵌套顶背驰精确定位, 卖点落在高位区')
855
+ score = max(0, min(110, score))
856
+ confidence = 'HIGH' if score >= 70 else ('MEDIUM' if score >= 45 else 'LOW')
857
+ if conf_reasons:
858
+ chain.append(('置信度', f'{confidence}(评分{score}) ← ' + '; '.join(conf_reasons),
859
+ '第21课: 一二三类买卖点是位置分类非质量排序; 置信度按原著力度条件评分'))
860
+ else:
861
+ chain.append(('置信度', f'HIGH(评分{score}) ← 力度条件全部满足',
862
+ '第27/29/43课: 方向一致+趋势背驰+趋势结构'))
863
+ note_parts = []
864
+ if conf_reasons:
865
+ note_parts.append(f'置信评分明细: ' + '; '.join(conf_reasons))
866
+ n_lvl = 3 + (self.df_60m is not None) + (self.df_15m is not None) + (self.df_5m is not None) + (self.df_1m is not None)
867
+ lvl_txt = {3:'三级别',4:'四级别',5:'五级别',6:'六级别',7:'七级别'}.get(n_lvl, f'{n_lvl}级别')
868
+ m5_txt = ('/5m已印证' if m5_confirmed is True else '/5m未印证(已降级)' if m5_confirmed is False else '')
869
+ m1_txt = ('/1m已印证' if m1_confirmed is True else '/1m未印证(已降级)' if m1_confirmed is False else '')
870
+ note_parts.append(f'{lvl_txt}联立通过: 周线{weekly_dir}/日线{daily_sig.kind}/30m已印证{m5_txt}{m1_txt}')
871
+ note = ' | '.join(note_parts)
872
+ return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
873
+ action=action, confidence=confidence, final_kind=daily_sig.kind, cur_price=cur_price,
874
+ chain=chain, note=note, confidence_reasons=conf_reasons, diagnostics=diagnostics, monthly=mov)
875
+
876
+ @staticmethod
877
+ def _no_signal_decision(wv, dv, cur_price):
878
+ if wv is not None and wv.trend == 'up_trend' and dv.trend == 'up_trend':
879
+ if dv.zg is not None and cur_price > dv.zg:
880
+ return ('HOLD','NONE', f'周线+日线均上涨且价({cur_price:.2f})在日线ZG({dv.zg:.2f})上, 继续持有等卖点(第108课: 买点入,持有等卖点)')
881
+ return ('HOLD','NONE','周线日线均上涨, 趋势中持有')
882
+ if wv is not None and wv.trend == 'down_trend':
883
+ return ('WATCH','NONE','周线下跌趋势, 无日线买点 → 空仓观望, 不抄底')
884
+ return ('WATCH','NONE', f'无明确信号(周线{wv.trend if wv else "?"}/日线{dv.trend}), 观望')
data_us.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ data_us.py — US market data layer (yfinance), replacing baostock/pytdx.
3
+
4
+ Levels & history limits (Yahoo Finance API constraints):
5
+ daily : 10 years (weekly / monthly are resampled from daily
6
+ by chan_multilevel.resample_weekly/_monthly)
7
+ 60m : last 730 days
8
+ 30m/15m : last 60 days
9
+ 5m : last 60 days
10
+ 1m : last 7 days only → too short for Chan decomposition, NOT used.
11
+ MultiLevelChan handles a missing 1m level gracefully (skips it).
12
+
13
+ Output schema (identical to the original A-share loaders):
14
+ date, open, close, high, low, volume, amount
15
+ `amount` (turnover) is approximated as close × volume (Yahoo has no turnover field).
16
+
17
+ All downloads are cached to parquet under ./_cache_us/<TICKER>/<level>.parquet
18
+ and refreshed when stale (daily: >12h old, intraday: >2h old) or on force=True.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import time
24
+ import traceback
25
+
26
+ import pandas as pd
27
+
28
+ CACHE_DIR = os.environ.get("CHAN_CACHE_DIR", "./_cache_us")
29
+
30
+ LEVELS = {
31
+ # level: (yfinance interval, period)
32
+ "d": ("1d", "10y"),
33
+ "60m": ("60m", "730d"),
34
+ "30m": ("30m", "60d"),
35
+ "15m": ("15m", "60d"),
36
+ "5m": ("5m", "60d"),
37
+ }
38
+
39
+ _STALE_SECONDS = {"d": 12 * 3600, "60m": 2 * 3600, "30m": 2 * 3600,
40
+ "15m": 2 * 3600, "5m": 2 * 3600}
41
+
42
+
43
+ def _cache_path(ticker: str, level: str) -> str:
44
+ d = os.path.join(CACHE_DIR, ticker.upper().replace("/", "_"))
45
+ os.makedirs(d, exist_ok=True)
46
+ return os.path.join(d, f"{level}.parquet")
47
+
48
+
49
+ def _normalize(df: pd.DataFrame) -> pd.DataFrame:
50
+ """yfinance frame → engine schema (date/open/close/high/low/volume/amount)."""
51
+ if df is None or len(df) == 0:
52
+ return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
53
+ d = df.copy()
54
+ if isinstance(d.columns, pd.MultiIndex): # yf>=0.2 returns MultiIndex sometimes
55
+ d.columns = [c[0] if isinstance(c, tuple) else c for c in d.columns]
56
+ d = d.reset_index()
57
+ # index column may be 'Date' or 'Datetime'
58
+ for cand in ("Datetime", "Date", "index"):
59
+ if cand in d.columns:
60
+ d = d.rename(columns={cand: "date"})
61
+ break
62
+ d.columns = [str(c).lower() for c in d.columns]
63
+ keep = {"date", "open", "high", "low", "close", "volume"}
64
+ d = d[[c for c in d.columns if c in keep]]
65
+ d["date"] = pd.to_datetime(d["date"])
66
+ # strip timezone so comparisons with naive Timestamps in the engine work
67
+ try:
68
+ d["date"] = d["date"].dt.tz_localize(None)
69
+ except (TypeError, AttributeError):
70
+ pass
71
+ d = d.dropna(subset=["open", "high", "low", "close"])
72
+ d = d.sort_values("date").reset_index(drop=True)
73
+ d["amount"] = d["close"] * d.get("volume", 0)
74
+ return d[["date", "open", "close", "high", "low", "volume", "amount"]]
75
+
76
+
77
+ def load_level(ticker: str, level: str, force: bool = False) -> pd.DataFrame:
78
+ """Load one level for a ticker, using parquet cache when fresh."""
79
+ assert level in LEVELS, f"unknown level {level}"
80
+ path = _cache_path(ticker, level)
81
+ if not force and os.path.exists(path):
82
+ age = time.time() - os.path.getmtime(path)
83
+ if age < _STALE_SECONDS[level]:
84
+ try:
85
+ return pd.read_parquet(path)
86
+ except Exception:
87
+ pass
88
+ try:
89
+ import yfinance as yf
90
+ interval, period = LEVELS[level]
91
+ raw = yf.Ticker(ticker).history(period=period, interval=interval,
92
+ auto_adjust=True, actions=False)
93
+ df = _normalize(raw)
94
+ if len(df):
95
+ df.to_parquet(path, index=False)
96
+ return df
97
+ except Exception:
98
+ traceback.print_exc()
99
+ # network failed → fall back to stale cache if any
100
+ if os.path.exists(path):
101
+ try:
102
+ return pd.read_parquet(path)
103
+ except Exception:
104
+ pass
105
+ return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
106
+
107
+
108
+ def load_all_levels(ticker: str, force: bool = False) -> dict:
109
+ """Return {'d':…, '60m':…, '30m':…, '15m':…, '5m':…} (1m intentionally absent)."""
110
+ return {lvl: load_level(ticker, lvl, force=force) for lvl in LEVELS}
111
+
112
+
113
+ def last_daily_date(ticker: str):
114
+ df = load_level(ticker, "d")
115
+ return None if df.empty else pd.Timestamp(df["date"].iloc[-1])
llm_local.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ llm_local.py — local small-model brain (llama.cpp runtime, no cloud APIs).
3
+
4
+ Earns the hackathon "Off the Grid" + "Llama Champion" badges:
5
+ * Models are Qwen3 GGUF files (all far below the 32B parameter cap)
6
+ * Inference runs through llama.cpp via llama-cpp-python
7
+ * The GGUF is downloaded once from the HF Hub, then everything is local
8
+
9
+ The model's job in this app is language work the rule engine can't do:
10
+ 1. Brief today's news on held US tickers, tagging POSITIVE/NEGATIVE/NEUTRAL.
11
+ 2. Read the sector ETF flow tables and narrate where capital is rotating.
12
+ 3. Write the Research Note (beta): valuation, moat, bull/bear case.
13
+ 4. Translate the (Chinese) Chan-theory decision chain into plain English.
14
+ """
15
+ import os
16
+ import re
17
+ import threading
18
+
19
+ from huggingface_hub import hf_hub_download
20
+
21
+ # name -> (HF repo, gguf filename, note)
22
+ MODEL_ZOO = {
23
+ "Qwen3-4B · fastest, fine on 2-vCPU Space": (
24
+ "Qwen/Qwen3-4B-GGUF", "Qwen3-4B-Q4_K_M.gguf"),
25
+ "Qwen3-8B · recommended balance": (
26
+ "Qwen/Qwen3-8B-GGUF", "Qwen3-8B-Q4_K_M.gguf"),
27
+ "Qwen3-14B · best quality (needs ~12 GB RAM / GPU Space)": (
28
+ "Qwen/Qwen3-14B-GGUF", "Qwen3-14B-Q4_K_M.gguf"),
29
+ }
30
+ DEFAULT_MODEL = "Qwen3-4B · fastest, fine on 2-vCPU Space"
31
+
32
+ _lock = threading.Lock()
33
+ _llm = None
34
+ _loaded_name = None
35
+
36
+ _THINK_RE = re.compile(r"<think>.*?</think>", re.S)
37
+
38
+
39
+ def status() -> str:
40
+ if _llm is None:
41
+ return "No model loaded — AI features are off. Pick a model and press Load."
42
+ return f"Loaded: {_loaded_name} (llama.cpp, local inference)"
43
+
44
+
45
+ def available() -> bool:
46
+ return _llm is not None
47
+
48
+
49
+ def load_model(name: str) -> str:
50
+ """Download (once) and load a GGUF model. Returns a status string."""
51
+ global _llm, _loaded_name
52
+ repo, fname = MODEL_ZOO[name]
53
+ with _lock:
54
+ if _loaded_name == name and _llm is not None:
55
+ return f"Already loaded: {name}"
56
+ try:
57
+ from llama_cpp import Llama
58
+ except Exception as e: # llama-cpp-python missing / failed to build
59
+ return f"llama-cpp-python is not available: {e}"
60
+ try:
61
+ path = hf_hub_download(repo_id=repo, filename=fname)
62
+ except Exception as e:
63
+ return f"Could not download {repo}/{fname}: {e}"
64
+ try:
65
+ _llm = None
66
+ _llm = Llama(
67
+ model_path=path,
68
+ n_ctx=8192,
69
+ n_threads=max(2, (os.cpu_count() or 4)),
70
+ verbose=False,
71
+ )
72
+ _loaded_name = name
73
+ return f"Loaded: {name}"
74
+ except Exception as e:
75
+ _llm = None
76
+ _loaded_name = None
77
+ return f"Failed to load model: {e}"
78
+
79
+
80
+ DEFAULT_SYSTEM = ("You are the analysis brain of Chan Compass, a US-equity "
81
+ "dashboard. Answer in clear, concise English.")
82
+
83
+
84
+ def is_loaded() -> bool:
85
+ return _llm is not None
86
+
87
+
88
+ def chat(user: str, max_tokens: int = 900, temperature: float = 0.3,
89
+ system: str = DEFAULT_SYSTEM) -> str:
90
+ """One chat turn. Qwen3 thinking mode is disabled with /no_think
91
+ (we want fast, deterministic summaries, not long reasoning traces)."""
92
+ if _llm is None:
93
+ return ""
94
+ with _lock:
95
+ try:
96
+ out = _llm.create_chat_completion(
97
+ messages=[
98
+ {"role": "system", "content": system + " /no_think"},
99
+ {"role": "user", "content": user},
100
+ ],
101
+ max_tokens=max_tokens,
102
+ temperature=temperature,
103
+ )
104
+ txt = out["choices"][0]["message"]["content"] or ""
105
+ return _THINK_RE.sub("", txt).strip()
106
+ except Exception as e:
107
+ return f"(model error: {e})"
news_watch.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ news_watch.py — daily news check for held tickers (US version, yfinance.news).
3
+
4
+ Rule requested by the user: for each holding, look only at TODAY's news.
5
+ If there is news → push a short AI brief. If there is none → ignore (the
6
+ ticker is listed under "Quiet today" so you know it was checked).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import datetime as dt
11
+ import os
12
+ import traceback
13
+
14
+ HOLDINGS_FILE = "./_app_output/holdings.txt"
15
+ os.makedirs(os.path.dirname(HOLDINGS_FILE), exist_ok=True)
16
+
17
+
18
+ # ── holdings persistence ─────────────────────────────────────────────────
19
+ def load_holdings() -> list:
20
+ try:
21
+ with open(HOLDINGS_FILE, encoding="utf-8") as f:
22
+ return [x.strip().upper() for x in f if x.strip()]
23
+ except FileNotFoundError:
24
+ return []
25
+
26
+
27
+ def save_holdings(tickers: list):
28
+ seen, out = set(), []
29
+ for t in tickers:
30
+ t = t.strip().upper()
31
+ if t and t not in seen:
32
+ seen.add(t)
33
+ out.append(t)
34
+ with open(HOLDINGS_FILE, "w", encoding="utf-8") as f:
35
+ f.write("\n".join(out))
36
+ return out
37
+
38
+
39
+ # ── news fetch ───────────────────────────────────────────────────────────
40
+ def _today_utc() -> dt.date:
41
+ return dt.datetime.now(dt.timezone.utc).date()
42
+
43
+
44
+ def fetch_today_news(ticker: str) -> list:
45
+ """Return today's news items: [{'title','publisher','time','link'}…]."""
46
+ try:
47
+ import yfinance as yf
48
+ items = yf.Ticker(ticker).news or []
49
+ except Exception:
50
+ traceback.print_exc()
51
+ return []
52
+ today = _today_utc()
53
+ out = []
54
+ for it in items:
55
+ # yfinance has two schemas: legacy flat dict, or {'content': {...}}
56
+ c = it.get("content", it)
57
+ title = c.get("title") or ""
58
+ ts = it.get("providerPublishTime")
59
+ when = None
60
+ if ts:
61
+ when = dt.datetime.fromtimestamp(ts, dt.timezone.utc)
62
+ else:
63
+ pub = c.get("pubDate") or c.get("displayTime")
64
+ if pub:
65
+ try:
66
+ when = dt.datetime.fromisoformat(str(pub).replace("Z", "+00:00"))
67
+ except ValueError:
68
+ when = None
69
+ if when is None or when.date() != today:
70
+ continue
71
+ pubr = c.get("publisher") or (c.get("provider") or {}).get("displayName") or ""
72
+ link = c.get("link") or (c.get("canonicalUrl") or {}).get("url") or ""
73
+ if title:
74
+ out.append({"title": title, "publisher": pubr,
75
+ "time": when.strftime("%H:%M UTC"), "link": link})
76
+ return out
77
+
78
+
79
+ def _llm_brief(ticker: str, items: list) -> str:
80
+ heads = "\n".join(f"- [{x['time']}] {x['title']} ({x['publisher']})" for x in items)
81
+ try:
82
+ import llm_local
83
+ if not llm_local.is_loaded():
84
+ return ""
85
+ prompt = (
86
+ f"You are an equity news analyst. Today's headlines for {ticker} "
87
+ f"(a stock the user currently HOLDS):\n{heads}\n\n"
88
+ "In English, ≤90 words: 1) one-line summary; 2) tag the net read as "
89
+ "POSITIVE / NEGATIVE / NEUTRAL for the holding; 3) one suggested "
90
+ "action (e.g. 'no action', 'review stop level', 'watch earnings'). "
91
+ "Be specific, no disclaimers."
92
+ )
93
+ return llm_local.chat(prompt, max_tokens=240)
94
+ except Exception:
95
+ return ""
96
+
97
+
98
+ def check_holdings_news(tickers=None) -> str:
99
+ """Markdown report: AI brief per holding with today-news; quiet list otherwise."""
100
+ tickers = tickers if tickers is not None else load_holdings()
101
+ if not tickers:
102
+ return ("**No holdings configured.** Add tickers above (e.g. `AAPL, NVDA`) "
103
+ "and save — they'll be checked for news every day.")
104
+ blocks, quiet = [], []
105
+ for t in tickers:
106
+ items = fetch_today_news(t)
107
+ if not items:
108
+ quiet.append(t)
109
+ continue
110
+ lines = [f"### 📰 {t} — {len(items)} item(s) today"]
111
+ for x in items[:6]:
112
+ link = f" · [link]({x['link']})" if x["link"] else ""
113
+ lines.append(f"- **{x['time']}** {x['title']} — *{x['publisher']}*{link}")
114
+ brief = _llm_brief(t, items)
115
+ if brief:
116
+ lines.append(f"\n> 🤖 **AI brief:** {brief}")
117
+ else:
118
+ lines.append("\n> _Load a model in the Model tab for an AI brief._")
119
+ blocks.append("\n".join(lines))
120
+ if quiet:
121
+ blocks.append(f"**Quiet today (no news, ignored):** {', '.join(quiet)}")
122
+ stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
123
+ return f"_Checked {stamp}_\n\n" + ("\n\n---\n\n".join(blocks) if blocks else "No output.")
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ gradio>=4.44
2
+ pandas>=2.0
3
+ numpy>=1.24
4
+ pyarrow>=14
5
+ yfinance>=0.2.40
6
+ apscheduler>=3.10
7
+ huggingface_hub>=0.23
8
+ llama-cpp-python>=0.2.90
research.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ research.py — Research Note (beta): a first slice of "Auto Research / auto report".
3
+
4
+ V1 scope (what this file does today):
5
+ input ticker → pull fundamentals via yfinance .info + recent headlines
6
+ → local LLM writes a structured English research note covering
7
+ valuation, moat / supply-chain position, bull vs bear case, key risks.
8
+
9
+ V2 (not built yet, see Automation tab description):
10
+ multi-step agentic research (filings, peers, news crawl) and an automatic
11
+ trigger that generates a report whenever a NEW ticker enters the pool.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import traceback
16
+
17
+
18
+ _FIELDS = [
19
+ ("longName", "Name"), ("sector", "Sector"), ("industry", "Industry"),
20
+ ("marketCap", "Market cap"), ("trailingPE", "P/E (ttm)"),
21
+ ("forwardPE", "P/E (fwd)"), ("priceToSalesTrailing12Months", "P/S (ttm)"),
22
+ ("priceToBook", "P/B"), ("enterpriseToEbitda", "EV/EBITDA"),
23
+ ("profitMargins", "Net margin"), ("grossMargins", "Gross margin"),
24
+ ("operatingMargins", "Operating margin"), ("returnOnEquity", "ROE"),
25
+ ("revenueGrowth", "Revenue growth (yoy)"), ("earningsGrowth", "Earnings growth (yoy)"),
26
+ ("freeCashflow", "Free cash flow"), ("totalCash", "Total cash"),
27
+ ("totalDebt", "Total debt"), ("dividendYield", "Dividend yield"),
28
+ ("beta", "Beta"), ("fiftyTwoWeekHigh", "52w high"), ("fiftyTwoWeekLow", "52w low"),
29
+ ("currentPrice", "Price"), ("targetMeanPrice", "Analyst mean target"),
30
+ ("recommendationKey", "Street view"),
31
+ ]
32
+
33
+
34
+ def _fmt(key: str, v):
35
+ if v is None:
36
+ return None
37
+ try:
38
+ if key in ("marketCap", "freeCashflow", "totalCash", "totalDebt"):
39
+ v = float(v)
40
+ return f"${v/1e9:,.1f}B" if abs(v) >= 1e9 else f"${v/1e6:,.0f}M"
41
+ if key in ("profitMargins", "grossMargins", "operatingMargins", "returnOnEquity",
42
+ "revenueGrowth", "earningsGrowth", "dividendYield"):
43
+ return f"{float(v):.1%}"
44
+ if isinstance(v, float):
45
+ return f"{v:,.2f}"
46
+ except (TypeError, ValueError):
47
+ pass
48
+ return str(v)
49
+
50
+
51
+ def gather_facts(ticker: str) -> tuple:
52
+ """Return (facts_markdown, facts_plain, error)."""
53
+ try:
54
+ import yfinance as yf
55
+ tk = yf.Ticker(ticker)
56
+ info = tk.info or {}
57
+ except Exception as e:
58
+ traceback.print_exc()
59
+ return "", "", f"Could not fetch fundamentals for {ticker}: {e}"
60
+ if not info or info.get("regularMarketPrice") is None and info.get("currentPrice") is None \
61
+ and not info.get("longName"):
62
+ return "", "", f"No fundamentals returned for {ticker} — check the symbol."
63
+ rows, plain = [], []
64
+ for key, label in _FIELDS:
65
+ val = _fmt(key, info.get(key))
66
+ if val is None:
67
+ continue
68
+ rows.append(f"| {label} | {val} |")
69
+ plain.append(f"{label}: {val}")
70
+ summary = (info.get("longBusinessSummary") or "")[:900]
71
+ heads = []
72
+ try:
73
+ import news_watch
74
+ for x in (tk.news or [])[:6]:
75
+ c = x.get("content", x)
76
+ t = c.get("title")
77
+ if t:
78
+ heads.append(t)
79
+ except Exception:
80
+ pass
81
+ md = f"**{info.get('longName', ticker)}** ({ticker})\n\n"
82
+ md += "| Metric | Value |\n|---|---|\n" + "\n".join(rows)
83
+ if heads:
84
+ md += "\n\n**Recent headlines:** " + " · ".join(heads[:5])
85
+ plain_txt = "\n".join(plain)
86
+ if summary:
87
+ plain_txt += f"\nBusiness: {summary}"
88
+ if heads:
89
+ plain_txt += "\nRecent headlines: " + " | ".join(heads)
90
+ return md, plain_txt, ""
91
+
92
+
93
+ def research_note(ticker: str) -> str:
94
+ """Generate the beta research note (markdown)."""
95
+ ticker = (ticker or "").strip().upper()
96
+ if not ticker:
97
+ return "Enter a ticker symbol first."
98
+ facts_md, facts_plain, err = gather_facts(ticker)
99
+ if err:
100
+ return f"⚠️ {err}"
101
+ note = ""
102
+ try:
103
+ import llm_local
104
+ if llm_local.is_loaded():
105
+ prompt = (
106
+ f"You are a buy-side equity analyst. Using ONLY the facts below, write a "
107
+ f"concise English research note on {ticker} (≤400 words) with EXACTLY these "
108
+ f"sections:\n"
109
+ f"## Snapshot\n## Valuation\n## Moat & supply-chain position\n"
110
+ f"## Bull case\n## Bear case\n## Key risks & verdict\n"
111
+ f"In Valuation, judge whether multiples look cheap/fair/rich vs the growth "
112
+ f"and margins shown. In Moat, infer the company's position in its industry "
113
+ f"value chain. Be specific; if a fact is missing, say 'n/a' rather than "
114
+ f"inventing it. No investment-advice disclaimers.\n\nFACTS:\n{facts_plain}"
115
+ )
116
+ note = llm_local.chat(prompt, max_tokens=900)
117
+ except Exception as e:
118
+ note = f"_(LLM error: {e})_"
119
+ if not note:
120
+ note = "_Load a model in the **Model** tab to generate the written analysis._"
121
+ return f"{facts_md}\n\n---\n\n{note}\n\n_Research Note (beta) · data: Yahoo Finance · model: local GGUF_"
rotation.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ rotation.py — US sector capital-rotation monitor.
3
+
4
+ True money-flow data for US equities (e.g. tick-rule buy/sell imbalance per
5
+ sector) is a paid dataset. The standard free proxy — used here — is the family
6
+ of 11 SPDR sector ETFs, which together cover the entire S&P 500:
7
+
8
+ flow_proxy($) = price_change% × dollar volume (close × volume)
9
+
10
+ A sector whose ETF rises on heavy dollar volume is absorbing capital; one that
11
+ falls on heavy dollar volume is shedding it. We also compute relative strength
12
+ vs SPY so "everything up / everything down" days don't mask rotation.
13
+
14
+ Windows: 1-day (today's rotation), 5-day (week trend), 20-day (month trend).
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import pandas as pd
19
+
20
+ import data_us
21
+
22
+ SECTOR_ETFS = {
23
+ "XLK": "Technology",
24
+ "XLF": "Financials",
25
+ "XLE": "Energy",
26
+ "XLV": "Health Care",
27
+ "XLY": "Consumer Discretionary",
28
+ "XLP": "Consumer Staples",
29
+ "XLI": "Industrials",
30
+ "XLB": "Materials",
31
+ "XLU": "Utilities",
32
+ "XLRE": "Real Estate",
33
+ "XLC": "Communication Services",
34
+ }
35
+ BENCH = "SPY"
36
+
37
+
38
+ def _window_stats(df: pd.DataFrame, n: int):
39
+ """Return (pct_change, avg dollar volume, flow proxy $) over last n bars."""
40
+ if df is None or len(df) < n + 1:
41
+ return None
42
+ closes = df["close"].iloc[-(n + 1):]
43
+ pct = float(closes.iloc[-1] / closes.iloc[0] - 1.0)
44
+ dvol = float((df["close"].iloc[-n:] * df["volume"].iloc[-n:]).mean())
45
+ return pct, dvol, pct * dvol
46
+
47
+
48
+ def build_rotation(force: bool = False):
49
+ """Compute the rotation table. Returns (df_1d, df_5d, df_20d, asof_str)."""
50
+ frames = {}
51
+ for tk in list(SECTOR_ETFS) + [BENCH]:
52
+ frames[tk] = data_us.load_level(tk, "d", force=force)
53
+
54
+ spy = frames[BENCH]
55
+ asof = "—"
56
+ if spy is not None and len(spy):
57
+ asof = pd.Timestamp(spy["date"].iloc[-1]).strftime("%Y-%m-%d")
58
+
59
+ out = {}
60
+ for n, label in ((1, "1D"), (5, "5D"), (20, "20D")):
61
+ rows = []
62
+ spy_stats = _window_stats(spy, n)
63
+ spy_pct = spy_stats[0] if spy_stats else 0.0
64
+ for tk, name in SECTOR_ETFS.items():
65
+ st = _window_stats(frames[tk], n)
66
+ if st is None:
67
+ continue
68
+ pct, dvol, flow = st
69
+ rows.append({
70
+ "Sector": name,
71
+ "ETF": tk,
72
+ "Change": pct,
73
+ "Avg $ Volume": dvol,
74
+ "Flow proxy": flow,
75
+ "RS vs SPY": pct - spy_pct,
76
+ })
77
+ if not rows:
78
+ out[label] = pd.DataFrame()
79
+ continue
80
+ df = pd.DataFrame(rows).sort_values("Flow proxy", ascending=False).reset_index(drop=True)
81
+ out[label] = df
82
+ return out.get("1D"), out.get("5D"), out.get("20D"), asof
83
+
84
+
85
+ def fmt_table(df: pd.DataFrame) -> pd.DataFrame:
86
+ """Human-readable formatting for the UI."""
87
+ if df is None or df.empty:
88
+ return pd.DataFrame(columns=["Sector", "ETF", "Change", "$ Volume (avg)",
89
+ "Flow proxy", "RS vs SPY", "Read"])
90
+ d = df.copy()
91
+
92
+ def _money(x):
93
+ ax = abs(x)
94
+ if ax >= 1e9:
95
+ return f"${x/1e9:,.2f}B"
96
+ if ax >= 1e6:
97
+ return f"${x/1e6:,.1f}M"
98
+ return f"${x:,.0f}"
99
+
100
+ def _read(row):
101
+ if row["Flow proxy"] > 0 and row["RS vs SPY"] > 0:
102
+ return "🟢 Inflow + leading"
103
+ if row["Flow proxy"] > 0:
104
+ return "🟩 Inflow"
105
+ if row["Flow proxy"] < 0 and row["RS vs SPY"] < 0:
106
+ return "🔴 Outflow + lagging"
107
+ if row["Flow proxy"] < 0:
108
+ return "🟥 Outflow"
109
+ return "—"
110
+
111
+ d["Read"] = d.apply(_read, axis=1)
112
+ d["$ Volume (avg)"] = d["Avg $ Volume"].map(_money)
113
+ d["Flow proxy"] = d["Flow proxy"].map(_money)
114
+ d["Change"] = d["Change"].map(lambda x: f"{x:+.2%}")
115
+ d["RS vs SPY"] = d["RS vs SPY"].map(lambda x: f"{x:+.2%}")
116
+ return d[["Sector", "ETF", "Change", "$ Volume (avg)", "Flow proxy", "RS vs SPY", "Read"]]
117
+
118
+
119
+ def rotation_brief(df_1d, df_5d, df_20d) -> str:
120
+ """Plain-text summary fed to the local LLM (and shown as fallback)."""
121
+ def top_bottom(df, label):
122
+ if df is None or df.empty:
123
+ return f"{label}: no data."
124
+ top = df.head(3)
125
+ bot = df.tail(3)
126
+ t = ", ".join(f"{r.Sector} ({r.Change:+.2%}, RS {r['RS vs SPY']:+.2%})"
127
+ for _, r in top.iterrows())
128
+ b = ", ".join(f"{r.Sector} ({r.Change:+.2%}, RS {r['RS vs SPY']:+.2%})"
129
+ for _, r in bot.iterrows())
130
+ return f"{label} — capital flowing INTO: {t}. Flowing OUT OF: {b}."
131
+ return "\n".join([top_bottom(df_1d, "1-day"),
132
+ top_bottom(df_5d, "5-day"),
133
+ top_bottom(df_20d, "20-day")])
134
+
135
+
136
+ def llm_narrative(df_1d, df_5d, df_20d) -> str:
137
+ """Ask the local model for a short rotation read; graceful fallback."""
138
+ brief = rotation_brief(df_1d, df_5d, df_20d)
139
+ try:
140
+ import llm_local
141
+ if not llm_local.is_loaded():
142
+ return ("_Load a model in the **Model** tab to get an AI rotation "
143
+ "narrative._\n\n**Raw read:**\n" + brief)
144
+ prompt = (
145
+ "You are a US equity market strategist. Based only on the sector "
146
+ "flow data below (SPDR sector ETF proxy: change% × dollar volume, "
147
+ "plus relative strength vs SPY), write a crisp English brief "
148
+ "(<180 words) with three parts: 1) Where capital is rotating INTO "
149
+ "and OUT OF right now; 2) whether 1-day moves agree with the 5/20-day "
150
+ "trend (rotation vs one-day noise); 3) one actionable watch item. "
151
+ "No disclaimers.\n\nDATA:\n" + brief
152
+ )
153
+ return llm_local.chat(prompt, max_tokens=420)
154
+ except Exception as e:
155
+ return f"**Raw read:**\n{brief}\n\n_(LLM unavailable: {e})_"
signal_runner.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ signal_runner.py — run the (unchanged) Chan multi-level engine over a US ticker pool.
3
+
4
+ Levels per ticker: monthly / weekly (resampled from 10y daily)
5
+ daily (yfinance 1d)
6
+ 60m / 30m / 15m / 5m (yfinance intraday)
7
+ 1m (not available beyond 7 days on Yahoo → skipped;
8
+ MultiLevelChan degrades gracefully)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import traceback
14
+
15
+ import pandas as pd
16
+
17
+ import chan_glue # noqa: F401 (wires engine into chan_multilevel, installs cached factory)
18
+ from chan_glue import MultiLevelChan, resample_weekly, resample_monthly
19
+ import chan_enhance
20
+ import data_us
21
+
22
+ DEFAULT_POOL = ["AAPL", "MSFT", "NVDA", "TSLA", "AMZN", "GOOGL", "META", "AMD", "NFLX", "JPM"]
23
+
24
+ OUT_DIR = "./_app_output"
25
+ os.makedirs(OUT_DIR, exist_ok=True)
26
+
27
+ ACTION_BADGE = {"BUY": "🟢 BUY", "SELL": "🔴 SELL", "HOLD": "🟡 HOLD", "WATCH": "⚪ WATCH"}
28
+ KIND_EN = {
29
+ "B1": "B1 · 1st buy (trend-end divergence)",
30
+ "B2": "B2 · 2nd buy (higher-low retest)",
31
+ "B3": "B3 · 3rd buy (pivot breakout retest)",
32
+ "S1": "S1 · 1st sell (top divergence)",
33
+ "S2": "S2 · 2nd sell (lower-high rebound)",
34
+ "S3": "S3 · 3rd sell (pivot breakdown)",
35
+ "STOP": "STOP · structural stop-loss",
36
+ "": "—",
37
+ }
38
+ TREND_EN = {"up_trend": "Up", "down_trend": "Down", "consolidation": "Range",
39
+ "expanding": "Expanding", "unknown": "?", "": "?"}
40
+
41
+
42
+ def analyze_one(ticker: str, force: bool = False):
43
+ """Run multi-level Chan analysis for one ticker. Returns (row_dict, detail_text)."""
44
+ dfs = data_us.load_all_levels(ticker, force=force)
45
+ d = dfs["d"]
46
+ if d is None or len(d) < 60:
47
+ return None, f"{ticker}: not enough daily history ({0 if d is None else len(d)} bars)."
48
+ w = resample_weekly(d)
49
+ m = resample_monthly(d)
50
+
51
+ ml = MultiLevelChan(
52
+ df_daily=d, df_weekly=w, df_monthly=m,
53
+ df_60m=dfs.get("60m"), df_30m=dfs.get("30m"),
54
+ df_15m=dfs.get("15m"), df_5m=dfs.get("5m"),
55
+ df_1m=None, # Yahoo 1m history (7 days) is too short for Chan decomposition
56
+ code=ticker, strict=True,
57
+ )
58
+ res = ml.analyze()
59
+ if res is None:
60
+ return None, f"{ticker}: analysis returned no result (insufficient structure)."
61
+
62
+ enh = chan_enhance.predict_enhance(res)
63
+ weight = enh.get("suggest_weight")
64
+ row = {
65
+ "Ticker": ticker,
66
+ "Action": ACTION_BADGE.get(res.action, res.action),
67
+ "Signal": KIND_EN.get(res.final_kind, res.final_kind or "—"),
68
+ "Confidence": res.confidence,
69
+ "Close": f"${res.cur_price:,.2f}",
70
+ "Weekly": TREND_EN.get(res.weekly.trend if res.weekly else "", "?"),
71
+ "Daily": TREND_EN.get(res.daily.trend if res.daily else "", "?"),
72
+ "Sell-trap armed": "Yes" if res.sell_armed else "—",
73
+ "Arm line": (f"${res.arm_zd:,.2f}" if res.arm_zd else "—"),
74
+ "Suggested weight": (f"{weight:.2f}" if weight else "—"),
75
+ "Note": (res.note or res.blocked_reason or "")[:160],
76
+ "_action_raw": res.action,
77
+ "_kind_raw": res.final_kind,
78
+ "_date": res.analysis_date.strftime("%Y-%m-%d"),
79
+ }
80
+ detail = res.explain()
81
+ extra_lines = []
82
+ for k in ("l16_note", "l37_note", "evo_hint", "l92_warn"):
83
+ if enh.get(k):
84
+ extra_lines.append(" " + enh[k])
85
+ if extra_lines:
86
+ detail += "\n ── 增强提示 (chan_enhance) ──\n" + "\n".join(extra_lines)
87
+ return row, detail
88
+
89
+
90
+ def run_signals(tickers=None, force: bool = False):
91
+ """Run the whole pool. Returns (DataFrame, {ticker: detail}, summary_str)."""
92
+ tickers = [t.strip().upper() for t in (tickers or DEFAULT_POOL) if t.strip()]
93
+ rows, details, errors = [], {}, []
94
+ for t in tickers:
95
+ try:
96
+ row, detail = analyze_one(t, force=force)
97
+ if row is None:
98
+ errors.append(detail)
99
+ continue
100
+ rows.append(row)
101
+ details[t] = detail
102
+ except Exception as e:
103
+ traceback.print_exc()
104
+ errors.append(f"{t}: {e}")
105
+ if rows:
106
+ df = pd.DataFrame(rows)
107
+ order = {"BUY": 0, "SELL": 1, "HOLD": 2, "WATCH": 3}
108
+ df["_o"] = df["_action_raw"].map(order).fillna(9)
109
+ df = df.sort_values(["_o", "Ticker"]).drop(columns=["_o"]).reset_index(drop=True)
110
+ show = df.drop(columns=[c for c in df.columns if c.startswith("_")])
111
+ try:
112
+ df.to_csv(os.path.join(OUT_DIR, "signals_latest.csv"), index=False)
113
+ except Exception:
114
+ pass
115
+ else:
116
+ show = pd.DataFrame(columns=["Ticker", "Action", "Signal", "Confidence", "Close"])
117
+ n_buy = sum(1 for r in rows if r["_action_raw"] == "BUY")
118
+ n_sell = sum(1 for r in rows if r["_action_raw"] == "SELL")
119
+ asof = rows[0]["_date"] if rows else "—"
120
+ summary = (f"Analyzed {len(rows)}/{len(tickers)} tickers · as of {asof} · "
121
+ f"{n_buy} BUY · {n_sell} SELL")
122
+ if errors:
123
+ summary += f" · {len(errors)} skipped"
124
+ return show, details, summary, errors