ranranrunforit commited on
Commit
a4d2fa1
·
verified ·
1 Parent(s): d098a15

Upload 24 files

Browse files
Files changed (19) hide show
  1. app.py +46 -483
  2. automation.py +60 -146
  3. base.css +13 -0
  4. chan_engine.py +508 -1445
  5. chan_enhance.py +141 -117
  6. chan_glue.py +1445 -64
  7. chan_multilevel.py +119 -880
  8. data_us.py +63 -140
  9. elevation.css +58 -0
  10. emailer.py +871 -275
  11. finetune_data.py +136 -94
  12. fonts.css +148 -0
  13. llm_local.py +271 -290
  14. news_watch.py +83 -167
  15. paths.py +286 -43
  16. spacing.css +35 -0
  17. styles.css +174 -0
  18. theme.py +82 -48
  19. typography.css +12 -0
app.py CHANGED
@@ -1,483 +1,46 @@
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 os
14
- import threading
15
-
16
- import gradio as gr
17
- import pandas as pd
18
-
19
- import automation
20
- import emailer
21
- import finetune_data
22
- import llm_local
23
- import news_watch
24
- import paths
25
- import research_agent
26
- import rotation
27
- import signal_runner
28
-
29
- # ─────────────────────────────────────────── Spectrum 2 theme ──
30
- # Single source of truth: theme.py from the Chan Compass · Spectrum 2 design
31
- # system (tokens mirrored from the design package's tokens/*.css).
32
- from theme import THEME as theme, CSS as S2_CSS
33
-
34
- # ─────────────────────────────────────────── UI callbacks ──
35
- def ui_run_signals(tickers_text, force):
36
- """Signals ONLY — pure rule engine, zero LLM, parallel downloads. Fast."""
37
- import time
38
- t0 = time.time()
39
- tickers = [t for t in tickers_text.replace("\n", ",").split(",") if t.strip()]
40
- df, details, summary, errors = signal_runner.run_signals(tickers or None,
41
- force=bool(force))
42
- if errors:
43
- summary += " · some tickers skipped (data not ready yet — run again)"
44
- automation.STATE["signals_df"] = df
45
- automation.STATE["signals_details"] = details
46
- automation.STATE["signals_summary"] = summary
47
- choices = sorted(details.keys())
48
- return (
49
- df if df is not None else pd.DataFrame(),
50
- f"{summary} · {time.time()-t0:.1f}s (rule engine only — no LLM in this path)",
51
- gr.update(choices=choices, value=(choices[0] if choices else None)),
52
- )
53
-
54
-
55
- def ui_show_detail(ticker):
56
- if not ticker:
57
- return "Select a ticker after running the analysis."
58
- raw = signal_runner.stock_raw_read(ticker)
59
- if not raw:
60
- return "No data for this ticker yet — run the analysis first."
61
- return f"**Raw read:**\n\n{raw}"
62
-
63
-
64
- def ui_explain_detail(ticker):
65
- raw = signal_runner.stock_raw_read(ticker or "")
66
- if not raw:
67
- yield "Run the analysis and select a ticker first."
68
- return
69
- # The full Chan ruling chain (Chinese) is kept BACKSTAGE in STATE and fed to
70
- # the model alongside the English raw read, so the summary reflects the real
71
- # multi-timeframe reasoning — but the chain is never shown and output is
72
- # English only. (This restores the merged raw-read + ruling-chain logic.)
73
- chain = automation.STATE.get("signals_details", {}).get(ticker or "", "")
74
- chain_core = chain.split("日线买卖点逐项诊断")[0].strip()[:2000] if chain else ""
75
- prompt = ("You are an equity analyst. Write a SHORT plain-English summary "
76
- "(≤100 words) for a long-term holder of a US stock: the situation "
77
- "today, whether to act or wait, and the key price levels.\n"
78
- "Use the FACT LINE for the numbers, and the RULING CHAIN (a "
79
- "Chinese multi-timeframe Chan-theory decision log) for the reasoning "
80
- "— translate and synthesize it; output ENGLISH ONLY, no Chinese "
81
- "characters, do not quote the log, no disclaimers.\n\n"
82
- f"FACT LINE:\n{raw}")
83
- if chain_core:
84
- prompt += f"\n\nRULING CHAIN (translate & synthesize, don't quote):\n{chain_core}"
85
- yield "🤖 _Summary Sub-Agent (Chan-Tuned Qwen3-1.7B · llama.cpp) is explaining…_"
86
- final = ""
87
- for acc in llm_local.chat_stream(prompt, max_tokens=260, temperature=0.2,
88
- worker="translator"):
89
- final = acc
90
- yield "🤖 **AI Summary (Summary Sub-Agent · Chan-Tuned Qwen3-1.7B):**\n\n" + acc
91
- # capture (raw read → narrative) as a fine-tuning pair (🎯 Well-Tuned)
92
- try:
93
- import finetune_data
94
- finetune_data.record(raw, final)
95
- except Exception:
96
- pass
97
-
98
-
99
- def ui_refresh_rotation():
100
- """Tables only — instant. AI narrative is a separate on-demand button."""
101
- d1, d5, d20, asof = rotation.build_rotation(force=True)
102
- automation.STATE["rotation"] = (d1, d5, d20, asof)
103
- raw = rotation.rotation_brief(d1, d5, d20)
104
- automation.STATE["rotation_narrative"] = raw
105
- return (rotation.fmt_table(d1), rotation.fmt_table(d5), rotation.fmt_table(d20),
106
- f"Sector flows as of **{asof}**", f"**Raw read:**\n{raw}")
107
-
108
-
109
- def ui_rotation_ai():
110
- d1, d5, d20, asof = automation.STATE["rotation"]
111
- if d1 is None:
112
- yield "Refresh the rotation tables first."
113
- return
114
- brief = rotation.rotation_brief(d1, d5, d20)
115
- prompt = ("You are a US equity market strategist. Based only on the sector flow "
116
- "data below (SPDR ETF proxy: change% × dollar volume, plus RS vs SPY), "
117
- "write a crisp brief (<150 words): 1) where capital is rotating INTO/OUT "
118
- "OF; 2) do 1-day moves agree with the 5/20-day trend; 3) one watch item. "
119
- "No disclaimers.\n\nDATA:\n" + brief[:2200])
120
- yield ("🤖 _Narrator sub-agent (Qwen3-1.7B · llama.cpp) is reading the flow "
121
- "tables — first words in ~5-15s…_")
122
- for acc in llm_local.chat_stream(prompt, max_tokens=340, worker="narrator"):
123
- yield "🤖 **Narrator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
124
-
125
-
126
- def ui_save_holdings(text):
127
- saved = news_watch.save_holdings(text.replace("\n", ",").split(","))
128
- return f"Saved {len(saved)} holding(s): {', '.join(saved) if saved else '—'}"
129
-
130
-
131
- def ui_check_news():
132
- last = ""
133
- for md in news_watch.check_holdings_news_stream():
134
- last = md
135
- yield md
136
- automation.STATE["news_md"] = last
137
-
138
-
139
- def ui_research(ticker):
140
- progress, report = "", ""
141
- for progress, report in research_agent.run_research_stream(ticker):
142
- yield progress, report, gr.update()
143
- reports = research_agent.list_reports()
144
- newest = reports[0] if reports else None
145
- yield progress, report, gr.update(choices=reports, value=newest)
146
-
147
-
148
- def ui_open_report(fname):
149
- return research_agent.read_report(fname)
150
-
151
-
152
- def ui_load_model(name):
153
- return llm_local.load_model(name, worker="deep")
154
-
155
-
156
-
157
- _SELFTEST = {"done": False, "result": ""}
158
-
159
-
160
- def _publish_traces(repo_id: str):
161
- """One-click: upload /data/traces to the Hub as a dataset, using the
162
- HF_TOKEN secret. No command line needed."""
163
- repo_id = (repo_id or "").strip()
164
- if "/" not in repo_id:
165
- return "⚠️ Enter a repo id like `username/dataset-name`."
166
- token = (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
167
- or "").strip()
168
- if not token:
169
- return ("⚠️ No `HF_TOKEN` secret found. Add it in Space → Settings → "
170
- "Variables and secrets (a **write** token), then try again.")
171
- try:
172
- import paths
173
- traces = [f for f in os.listdir(paths.TRACES_DIR) if f.endswith(".json")]
174
- except OSError:
175
- traces = []
176
- if not traces:
177
- return "⚠️ No traces yet — run a few Auto Research reports first."
178
- try:
179
- from huggingface_hub import HfApi
180
- api = HfApi(token=token)
181
- api.create_repo(repo_id, repo_type="dataset", exist_ok=True)
182
- # a small README so the dataset page explains itself
183
- card = (
184
- "---\nlicense: mit\ntags: [agent-trace, finance, chan-theory, llama-cpp]\n---\n\n"
185
- "# Chan Compass — agent traces\n\n"
186
- "JSON traces from the Chan Compass multi-agent research desk. Each file "
187
- "is one ticker's run: the plan, every evidence-tool call and its result, "
188
- "and each local sub-agent's request and response. Shared for the "
189
- "Build Small hackathon (*Sharing is Caring*).\n\n"
190
- "*Educational data — not investment advice.*\n")
191
- import tempfile
192
- rp = os.path.join(tempfile.gettempdir(), "README.md")
193
- with open(rp, "w", encoding="utf-8") as f:
194
- f.write(card)
195
- api.upload_file(path_or_fileobj=rp, path_in_repo="README.md",
196
- repo_id=repo_id, repo_type="dataset")
197
- api.upload_folder(folder_path=paths.TRACES_DIR, path_in_repo="traces",
198
- repo_id=repo_id, repo_type="dataset")
199
- url = f"https://huggingface.co/datasets/{repo_id}"
200
- return (f"✅ Published **{len(traces)}** trace(s) to [{repo_id}]({url}). "
201
- f"Put that link in your submission for the *Sharing is Caring* badge.")
202
- except Exception as e:
203
- return f"❌ Upload failed: {e}"
204
-
205
-
206
- def _export_dataset():
207
- n = finetune_data.count()
208
- if n == 0:
209
- return ("⚠️ **0 training pairs captured yet.** Pairs are saved only when "
210
- "the **Signals → AI summary** finishes with a model loaded. Steps: "
211
- "1) Model tab — wait for the Summary sub-agent to show ✅; "
212
- "2) Signals — Run analysis, pick a ticker, click **AI summary**, "
213
- "let it finish; repeat a few times; 3) come back and Export.",
214
- gr.update(visible=False))
215
- path = finetune_data.export()
216
- if not path:
217
- return ("⚠️ Export failed to write the file (storage error). Try again.",
218
- gr.update(visible=False))
219
- # Copy to a folder under the app's working dir, which Gradio serves
220
- # reliably (the /data bucket and /tmp are not in Gradio's allowed paths).
221
- import shutil
222
- served_dir = os.path.join(os.getcwd(), "exports")
223
- os.makedirs(served_dir, exist_ok=True)
224
- served = os.path.join(served_dir, os.path.basename(path))
225
- try:
226
- shutil.copy(path, served)
227
- except OSError:
228
- served = path
229
- msg = (f"✅ Exported **{n}** captured pair(s). Download below, then follow "
230
- f"`finetune/FINETUNE_GUIDE.md` to LoRA-tune Qwen3-1.7B and publish it.")
231
- return msg, gr.update(value=served, visible=True)
232
-
233
-
234
- def _run_selftest():
235
- """Run the self-test now (used by both the auto-timer and the manual button)
236
- and cache the verdict so the two never fight over the output box."""
237
- out = llm_local.quick_test()
238
- ok = "not loaded" not in out and "error" not in out.lower()
239
- _SELFTEST["done"] = True
240
- _SELFTEST["result"] = (("✅ **Every agent is OK now** — self-test passed:\n\n" + out)
241
- if ok else ("⚠️ Self-test finished with issues:\n\n" + out))
242
- return _SELFTEST["result"]
243
-
244
-
245
- def _auto_selftest():
246
- """Auto-runs ONCE the moment every sub-agent is loaded. After that it returns
247
- the cached verdict unchanged, so it never overwrites a manual test result."""
248
- if _SELFTEST["done"]:
249
- return _SELFTEST["result"]
250
- workers = llm_local.WORKERS
251
- if not all(w["llm"] is not None for w in workers.values()):
252
- ready = sum(1 for w in workers.values() if w["llm"] is not None)
253
- return f"⏳ Loading sub-agents… {ready}/{len(workers)} ready (self-test will run automatically)."
254
- return _run_selftest()
255
-
256
-
257
- def _manual_selftest():
258
- """Manual button: always re-runs and shows the fresh verdict."""
259
- return _run_selftest()
260
-
261
-
262
- # ─────────────────────────────────────────── layout ──
263
- _GR_MAJOR = int(gr.__version__.split(".")[0])
264
- _style_kw = {} if _GR_MAJOR >= 6 else {"theme": theme, "css": S2_CSS}
265
-
266
- with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
267
- gr.HTML("""
268
- <div id="s2-hero">
269
- <div class="mark">🧭</div>
270
- <div>
271
- <h1>Chan Compass <span>· US Markets</span></h1>
272
- <p>Multi-timeframe 缠论 (Chan theory) engine — monthly → 1m nested-interval
273
- confirmation · sector rotation · local sub-agent pool (llama.cpp).</p>
274
- </div>
275
- <div class="chips">
276
- <span>🧠 Local · no cloud APIs</span><span>🦙 llama.cpp</span>
277
- <span>🤖 4 sub-agents · 1.7B+4B</span><span>📊 Yahoo Finance</span>
278
- <span>⏰ 18:10 ET</span><span>💾 /data bucket</span><span>🎨 Spectrum 2</span>
279
- </div>
280
- </div>""")
281
-
282
- with gr.Tab("📈 Signals"):
283
- with gr.Row():
284
- tickers_in = gr.Textbox(value=", ".join(signal_runner.DEFAULT_POOL),
285
- label="Ticker pool (comma separated)", scale=4)
286
- force_cb = gr.Checkbox(value=False, label="Force fresh download", scale=1)
287
- run_btn = gr.Button("▶ Run analysis", variant="primary", scale=1)
288
- sig_summary = gr.Markdown(automation.STATE["signals_summary"])
289
- sig_table = gr.Dataframe(label="Tomorrow's plan — long-hold mode (sorted: BUY → SELL → HOLD → WAIT)",
290
- interactive=False, wrap=True)
291
- gr.Markdown("**Stock summary** — pick a ticker for a plain-English raw read, "
292
- "then let the Summary sub-agent write an AI Summary.",
293
- elem_classes=["s2-footnote"])
294
- with gr.Row():
295
- detail_pick = gr.Dropdown(choices=[], label="Ticker", scale=2)
296
- explain_btn = gr.Button("🤖 AI summary (local LLM)", scale=1)
297
- detail_box = gr.Markdown(label="Raw read")
298
- explain_box = gr.Markdown(elem_classes=["llm-out"])
299
- with gr.Row():
300
- sig_email = gr.Textbox(label="Email this summary to", scale=4,
301
- placeholder="name@example.com (comma-separate for several)")
302
- sig_email_btn = gr.Button("✉ Send Email", scale=1)
303
- sig_email_status = gr.Markdown()
304
-
305
- with gr.Tab("🔄 Sector Rotation"):
306
- gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500 coverage). "
307
- "**Flow proxy = price change % × dollar volume**; RS = return minus SPY. "
308
- "True per-sector fund-flow feeds are paid data — this is the standard free proxy.")
309
- with gr.Row():
310
- rot_btn = gr.Button("↻ Refresh rotation (instant)", variant="primary")
311
- rot_ai_btn = gr.Button("🤖 AI narrative (local LLM)")
312
- rot_asof = gr.Markdown("Press refresh (or Run analysis on the Signals tab).")
313
- with gr.Row():
314
- rot_1d = gr.Dataframe(label="1-Day (today's rotation)", interactive=False)
315
- with gr.Row():
316
- rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
317
- rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
318
- rot_ai = gr.Markdown(label="AI rotation narrative", elem_classes=["llm-out"])
319
- with gr.Row():
320
- rot_email = gr.Textbox(label="Email this narrative to", scale=4,
321
- placeholder="name@example.com (comma-separate for several)")
322
- rot_email_btn = gr.Button("✉ Send Email", scale=1)
323
- rot_email_status = gr.Markdown()
324
-
325
- with gr.Tab("📰 Watchlist News"):
326
- gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
327
- "News found → AI brief is pushed below. No news → the ticker is ignored "
328
- "(listed under *Quiet today*).")
329
- with gr.Row():
330
- hold_in = gr.Textbox(value=", ".join(news_watch.load_holdings()),
331
- label="My holdings (comma separated)", scale=3)
332
- save_btn = gr.Button("💾 Save holdings", scale=1)
333
- news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
334
- hold_status = gr.Markdown()
335
- news_out = gr.Markdown(elem_classes=["llm-out"])
336
- with gr.Row():
337
- news_email = gr.Textbox(label="Email this news brief to", scale=4,
338
- placeholder="name@example.com (comma-separate for several)")
339
- news_email_btn = gr.Button("✉ Send Email", scale=1)
340
- news_email_status = gr.Markdown()
341
-
342
- with gr.Tab("🧪 Auto Research"):
343
- gr.Markdown("**Multi-step research agent** (fully local): PLAN → 5 evidence tools "
344
- "(fundamentals · quarterly financials · price action · **the Chan engine "
345
- "itself** · news) → section-by-section analysis → report. Every step is "
346
- "logged to a JSON **agent trace** on persistent storage. "
347
- "New tickers entering the signal pool get a report **auto-generated** "
348
- "by the daily pipeline.")
349
- with gr.Row():
350
- res_in = gr.Textbox(label="Ticker", placeholder="e.g. NVDA", scale=3)
351
- res_btn = gr.Button("🤖 Run research agent", variant="primary", scale=1)
352
- res_progress = gr.Markdown()
353
- res_out = gr.Markdown(elem_classes=["llm-out"])
354
- with gr.Row():
355
- res_email = gr.Textbox(label="Email this report to", scale=4,
356
- placeholder="name@example.com (comma-separate for several)")
357
- res_email_btn = gr.Button("✉ Send Email", scale=1)
358
- res_email_status = gr.Markdown()
359
- gr.Markdown("**Report library** (auto + manual, stored on `/data`):",
360
- elem_classes=["s2-footnote"])
361
- with gr.Row():
362
- rep_pick = gr.Dropdown(choices=research_agent.list_reports(),
363
- label="Saved reports", scale=3)
364
- rep_open = gr.Button("📂 Open report", scale=1)
365
- rep_view = gr.Markdown()
366
-
367
- with gr.Tab("⏰ Automation"):
368
- gr.Markdown(paths.storage_status())
369
- auto_md = gr.Markdown(automation.schedule_info())
370
- with gr.Row():
371
- auto_now = gr.Button("⚡ Run now", variant="primary")
372
- auto_msg = gr.Markdown()
373
- auto_log = gr.Textbox(lines=14, label="Pipeline log (live — updates every 2s)",
374
- elem_id="detail-log")
375
- traces_md = gr.Markdown(research_agent.list_traces())
376
- gr.Markdown("**📡 Share traces** — publish every JSON agent trace in "
377
- "`/data/traces` as a Hugging Face **dataset** (one click, uses "
378
- "your `HF_TOKEN` secret — no command line). Earns *Sharing is "
379
- "Caring*.", elem_classes=["s2-footnote"])
380
- with gr.Row():
381
- trace_repo = gr.Textbox(
382
- value="ranranrunforit/chan-compass-agent-traces",
383
- label="Dataset repo id", scale=3)
384
- trace_pub = gr.Button("📡 Publish traces as dataset", scale=1)
385
- trace_pub_status = gr.Markdown()
386
- trace_pub.click(_publish_traces, trace_repo, trace_pub_status)
387
- auto_log_timer = gr.Timer(2.0)
388
-
389
- def _auto_tick():
390
- log = "\n".join(automation.STATE["log"][-40:]) or "(no log yet)"
391
- return log, research_agent.list_traces()
392
-
393
- auto_log_timer.tick(_auto_tick, None, [auto_log, traces_md])
394
-
395
- with gr.Tab("🧠 Model"):
396
- gr.Markdown("All AI runs **locally** through **llama.cpp** (llama-cpp-python) with "
397
- "Qwen3 GGUF weights — every option is far below the 32B-parameter cap, "
398
- "and nothing leaves the machine. **First load installs the llama.cpp "
399
- "runtime + downloads the GGUF (one-time, usually 1–3 min; worst case "
400
- "~15 min if it has to compile).** Signals/rotation/news never depend on it.")
401
- gr.Markdown("**Sub-agent pool:** Summary Sub-Agent (Chan-Tuned Qwen3-1.7B, "
402
- "fixed) handles Explain / rotation narrative / news briefs; "
403
- "`deep` Analyst writes research reports. Each has its own lock — "
404
- "they run in parallel. Pick the Analyst model below:")
405
- model_pick = gr.Radio(choices=list(llm_local.MODEL_ZOO.keys()),
406
- value=llm_local.DEFAULT_MODEL, label="Analyst (deep) model")
407
- with gr.Row():
408
- load_btn = gr.Button("⬇ Load model", variant="primary")
409
- test_btn = gr.Button("⚡ Test sub-agents now")
410
- model_status = gr.Markdown(llm_local.status())
411
- model_test_out = gr.Markdown()
412
- model_timer = gr.Timer(2.0)
413
- model_timer.tick(lambda: llm_local.status(), None, model_status)
414
- autotest_timer = gr.Timer(3.0)
415
- autotest_timer.tick(_auto_selftest, None, model_test_out)
416
-
417
- gr.Markdown("---\n### 🎯 Fine-tuning dataset (Well-Tuned badge)\n"
418
- "Every Signals **AI summary** you run is saved as a "
419
- "(raw read → narrative) training pair on `/data`. Capture a "
420
- "few hundred, export the JSONL, then follow `FINETUNE_GUIDE.md` "
421
- "to LoRA-tune Qwen3-1.7B and publish it.")
422
- ft_status = gr.Markdown(finetune_data.status_line())
423
- ft_export = gr.Button("⬇ Export dataset (JSONL)", variant="primary")
424
- ft_out = gr.Markdown()
425
- ft_file = gr.File(label="Download training data", visible=False)
426
- ft_timer = gr.Timer(3.0)
427
- ft_timer.tick(lambda: finetune_data.status_line(), None, ft_status)
428
- ft_export.click(_export_dataset, None, [ft_out, ft_file])
429
-
430
- gr.Markdown("Chan Compass · educational tool, not investment advice · "
431
- "data: Yahoo Finance · design language: Adobe Spectrum 2",
432
- elem_classes=["s2-footnote"])
433
-
434
- # wiring
435
- run_btn.click(ui_run_signals, [tickers_in, force_cb],
436
- [sig_table, sig_summary, detail_pick])
437
- detail_pick.change(ui_show_detail, detail_pick, detail_box)
438
- explain_btn.click(ui_explain_detail, detail_pick, explain_box)
439
- rot_btn.click(ui_refresh_rotation, None, [rot_1d, rot_5d, rot_20d, rot_asof, rot_ai])
440
- rot_ai_btn.click(ui_rotation_ai, None, rot_ai)
441
- save_btn.click(ui_save_holdings, hold_in, hold_status)
442
- news_btn.click(ui_check_news, None, news_out)
443
- res_btn.click(ui_research, res_in, [res_progress, res_out, rep_pick])
444
- sig_email_btn.click(lambda body, to: emailer.send_result(body, to, "Signals summary"),
445
- [explain_box, sig_email], sig_email_status)
446
- rot_email_btn.click(lambda body, to: emailer.send_result(body, to, "Sector rotation"),
447
- [rot_ai, rot_email], rot_email_status)
448
- news_email_btn.click(lambda body, to: emailer.send_result(body, to, "Watchlist news"),
449
- [news_out, news_email], news_email_status)
450
- res_email_btn.click(lambda body, to: emailer.send_result(body, to, "Research report"),
451
- [res_out, res_email], res_email_status)
452
- rep_open.click(ui_open_report, rep_pick, rep_view)
453
- auto_now.click(lambda: automation.run_pipeline(force=True), None, auto_msg)
454
- load_btn.click(ui_load_model, model_pick, model_status)
455
- test_btn.click(_manual_selftest, None, model_test_out)
456
-
457
- automation.start_scheduler()
458
-
459
- # Auto-load the default local model in the background so AI features (English
460
- # explanations, rotation narrative, research agent) are ready without a click.
461
- def _auto_load_model():
462
- try:
463
- automation._log("Auto-loading sub-agents (llama.cpp)…")
464
- llm_local.auto_load_all()
465
- except Exception as e:
466
- automation._log(f"Sub-agent auto-load failed: {e}")
467
-
468
- try:
469
- import paths as _paths
470
- automation._log(_paths.storage_status())
471
- except Exception:
472
- pass
473
- if os.environ.get("AUTO_LOAD_MODEL", "1") == "1":
474
- threading.Thread(target=_auto_load_model, daemon=True).start()
475
-
476
- if __name__ == "__main__":
477
- import paths as _p
478
- _allowed = [os.path.join(os.getcwd(), "exports"), _p.DATASET_DIR]
479
- os.makedirs(_allowed[0], exist_ok=True)
480
- if _GR_MAJOR >= 6:
481
- demo.launch(theme=theme, css=S2_CSS, allowed_paths=_allowed)
482
- else:
483
- demo.launch(allowed_paths=_allowed)
 
1
+ /* ============================================================
2
+ SPACING & SIZING — Chan Compass · Spectrum 2
3
+ Spectrum 2 spacing scale (px): 0 2 4 8 12 16 20 24 28 32 40 48 64 80 96.
4
+ 8px is the base rhythm. Padding uses px; layout gaps reuse the same scale.
5
+ ============================================================ */
6
+
7
+ :root {
8
+ --space-0: 0;
9
+ --space-50: 2px;
10
+ --space-75: 4px;
11
+ --space-100: 8px; /* base unit */
12
+ --space-150: 12px;
13
+ --space-200: 16px;
14
+ --space-250: 20px;
15
+ --space-300: 24px;
16
+ --space-400: 32px;
17
+ --space-500: 40px;
18
+ --space-600: 48px;
19
+ --space-700: 64px;
20
+ --space-800: 80px;
21
+ --space-900: 96px;
22
+
23
+ /* ---- Corner radii (Spectrum 2) ---- */
24
+ --radius-none: 0;
25
+ --radius-sm: 4px; /* chips, small controls */
26
+ --radius-default: 8px; /* inputs, buttons (square)*/
27
+ --radius-lg: 10px; /* nested cards */
28
+ --radius-xl: 16px; /* cards, panels */
29
+ --radius-2xl: 20px; /* hero, large surfaces */
30
+ --radius-full: 9999px; /* pills, avatars */
31
+
32
+ /* ---- Border widths ---- */
33
+ --border-width-100: 1px;
34
+ --border-width-200: 2px;
35
+ --border-width-400: 4px;
36
+
37
+ /* ---- Control heights (medium scale) ---- */
38
+ --control-height-sm: 28px;
39
+ --control-height-md: 32px;
40
+ --control-height-lg: 40px;
41
+
42
+ /* ---- Layout ---- */
43
+ --container-max: 1280px;
44
+ --field-radius: var(--radius-default);
45
+ --card-radius: var(--radius-xl);
46
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
automation.py CHANGED
@@ -1,147 +1,61 @@
1
- """
2
- automation.pydaily 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
- # LLM narrative is generated ON DEMAND from the Rotation tab button —
73
- # the pipeline itself never waits on the model.
74
- STATE["rotation_narrative"] = rotation.rotation_brief(d1, d5, d20)
75
- _log(f"Sector rotation rebuilt (as of {asof}).")
76
-
77
- STATE["news_md"] = news_watch.check_holdings_news()
78
- _log("Holdings news checked.")
79
-
80
- # ── Feature 4: auto research report for every NEW ticker in the pool ──
81
- try:
82
- import json
83
- import os
84
- import paths
85
- import research_agent
86
- known_path = os.path.join(paths.OUTPUT_DIR, "known_tickers.json")
87
- try:
88
- with open(known_path, encoding="utf-8") as f:
89
- known = set(json.load(f))
90
- except (OSError, ValueError):
91
- known = set()
92
- current = set(df["Ticker"].tolist()) if df is not None and len(df) else set()
93
- new_tickers = sorted(current - known)
94
- generated = set()
95
- for t in new_tickers[:5]: # safety cap per run
96
- _log(f"New ticker {t} → auto-generating research report…")
97
- report, trace = research_agent.run_research(t, auto=True)
98
- if report:
99
- generated.add(t)
100
- _log(f"Report for {t} done{' (+trace)' if trace else ''}.")
101
- else:
102
- _log(f"Report for {t} postponed (sub-agents still loading) — "
103
- f"will retry on the next run.")
104
- done_set = known | (current - (set(new_tickers) - generated))
105
- if current:
106
- with open(known_path, "w", encoding="utf-8") as f:
107
- json.dump(sorted(done_set), f)
108
- except Exception as e:
109
- _log(f"Auto-research skipped: {e}")
110
-
111
- STATE["last_run"] = dt.datetime.now(NY)
112
- _log("Pipeline finished.")
113
- return f"Done. {summary}"
114
- except Exception as e:
115
- traceback.print_exc()
116
- _log(f"Pipeline error: {e}")
117
- return f"Pipeline error: {e}"
118
- finally:
119
- STATE["running"] = False
120
-
121
-
122
- def start_scheduler():
123
- """Cron: Mon–Fri 18:10 America/New_York."""
124
- try:
125
- from apscheduler.schedulers.background import BackgroundScheduler
126
- from apscheduler.triggers.cron import CronTrigger
127
- except Exception as e:
128
- _log(f"APScheduler unavailable: {e}")
129
- return None
130
- sched = BackgroundScheduler(timezone=NY)
131
- sched.add_job(run_pipeline, CronTrigger(day_of_week="mon-fri",
132
- hour=RUN_HOUR, minute=RUN_MINUTE),
133
- id="daily_pipeline", max_instances=1, coalesce=True)
134
- sched.start()
135
- _log(f"Scheduler armed: Mon–Fri {RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York.")
136
- return sched
137
-
138
-
139
- def schedule_info() -> str:
140
- now = dt.datetime.now(NY)
141
- last = STATE["last_run"].strftime("%Y-%m-%d %H:%M ET") if STATE["last_run"] else "never"
142
- return (f"**Schedule:** Mon–Fri **{RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York** "
143
- f"(market closes 16:00 ET; by 18:10 the official daily bar has settled — "
144
- f"that's 07:10 next morning Beijing time).\n\n"
145
- f"**Now (ET):** {now.strftime('%Y-%m-%d %H:%M')} · **Last run:** {last}\n\n"
146
- f"⚠️ On free Space hardware the app sleeps when idle and the timer can't fire; "
147
- f"use **Run now**, or upgrade to always-on hardware for unattended updates.")
 
1
+ /* ============================================================
2
+ TYPOGRAPHYChan Compass · Spectrum 2
3
+ Spectrum 2 desktop UI base = 14px, modular scale ratio 1.125.
4
+ Three families: sans (UI/headings), serif (editorial), code (mono).
5
+ ============================================================ */
6
+
7
+ :root {
8
+ /* ---- Families ---- */
9
+ --font-sans: 'Source Sans 3', 'Adobe Clean', ui-sans-serif, system-ui, -apple-system, sans-serif;
10
+ --font-serif: 'Source Serif 4', 'Adobe Clean Serif', Georgia, 'Times New Roman', serif;
11
+ --font-code: 'Source Code Pro', ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
12
+
13
+ /* ---- Type scale (ratio 1.125, base 14px) ---- */
14
+ --font-size-50: 11px;
15
+ --font-size-75: 12px;
16
+ --font-size-100: 14px; /* UI base */
17
+ --font-size-200: 16px;
18
+ --font-size-300: 18px;
19
+ --font-size-400: 20px;
20
+ --font-size-500: 22px;
21
+ --font-size-600: 25px;
22
+ --font-size-700: 28px;
23
+ --font-size-800: 32px;
24
+ --font-size-900: 36px;
25
+ --font-size-1000: 40px;
26
+ --font-size-1100: 45px;
27
+ --font-size-1200: 50px;
28
+
29
+ /* ---- Weights (Source Sans 3 axis) ---- */
30
+ --font-weight-regular: 400;
31
+ --font-weight-medium: 500;
32
+ --font-weight-semibold: 600;
33
+ --font-weight-bold: 700;
34
+ --font-weight-black: 800;
35
+
36
+ /* ---- Line heights ---- */
37
+ --line-height-ui: 1.3; /* UI components, tight */
38
+ --line-height-heading: 1.15; /* large display headings */
39
+ --line-height-body: 1.5; /* paragraphs, spacious */
40
+ --line-height-code: 1.45;
41
+
42
+ /* ---- Letter spacing ---- */
43
+ --letter-spacing-heading: -0.015em; /* Spectrum tightens large type */
44
+ --letter-spacing-ui: 0;
45
+ --letter-spacing-caps: 0.06em; /* eyebrows / overlines */
46
+
47
+ /* ============================================================
48
+ SEMANTIC TYPE ROLES
49
+ ============================================================ */
50
+ --type-display: var(--font-weight-bold) var(--font-size-1000)/var(--line-height-heading) var(--font-sans);
51
+ --type-h1: var(--font-weight-bold) var(--font-size-800)/var(--line-height-heading) var(--font-sans);
52
+ --type-h2: var(--font-weight-bold) var(--font-size-600)/var(--line-height-heading) var(--font-sans);
53
+ --type-h3: var(--font-weight-semibold) var(--font-size-400)/var(--line-height-ui) var(--font-sans);
54
+ --type-h4: var(--font-weight-semibold) var(--font-size-300)/var(--line-height-ui) var(--font-sans);
55
+ --type-body-lg: var(--font-weight-regular) var(--font-size-300)/var(--line-height-body) var(--font-sans);
56
+ --type-body: var(--font-weight-regular) var(--font-size-100)/var(--line-height-body) var(--font-sans);
57
+ --type-ui: var(--font-weight-regular) var(--font-size-100)/var(--line-height-ui) var(--font-sans);
58
+ --type-ui-bold: var(--font-weight-semibold) var(--font-size-100)/var(--line-height-ui) var(--font-sans);
59
+ --type-detail: var(--font-weight-regular) var(--font-size-75)/var(--line-height-ui) var(--font-sans);
60
+ --type-code: var(--font-weight-regular) var(--font-size-75)/var(--line-height-code) var(--font-code);
61
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
base.css ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ Chan Compass · Spectrum 2 Design System
3
+ Global entry point — consumers link THIS one file.
4
+ Import order: fonts → primitives → semantics. Keep this file
5
+ as @import lines only.
6
+ ============================================================ */
7
+
8
+ @import url('tokens/fonts.css');
9
+ @import url('tokens/colors.css');
10
+ @import url('tokens/typography.css');
11
+ @import url('tokens/spacing.css');
12
+ @import url('tokens/elevation.css');
13
+ @import url('tokens/base.css');
chan_engine.py CHANGED
@@ -1,1451 +1,514 @@
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
 
1
  """
2
+ Chan Compass — US edition · Spectrum 2 frontend (redesigned layout)
3
+ ====================================================================
4
+ Same backend, cleaner face. EVERY feature of v3.9 is preserved — all six
5
+ tabs, every callback, every timer, the email rows, the fine-tune export and
6
+ the trace publisher — only the layout is reorganised for a simpler, more
7
+ beautiful Adobe Spectrum 2 experience:
8
+
9
+ • each tab opens with a framed "control bar" grouping its primary inputs
10
+ • the per-tab "Email this result" row is tucked into a collapsible accordion
11
+ • short eyebrow labels + help text replace dense paragraphs
12
+ • the signature accent-framed AI output panel (.llm-out)
13
+
14
+ The UI is mounted on a server with gr.mount_gradio_app (see server.py) — or
15
+ just `demo.launch()` here. Backend modules are imported unchanged.
16
  """
17
  from __future__ import annotations
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
 
19
+ import os
20
+ import threading
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
+ import gradio as gr
23
+ import pandas as pd
24
 
25
+ import automation
26
+ import emailer
27
+ import finetune_data
28
+ import llm_local
29
+ import news_watch
30
+ import paths
31
+ import research_agent
32
+ import rotation
33
+ import signal_runner
34
+
35
+ # Spectrum 2 theme single source of truth (tokens mirror the design system).
36
+ from theme import THEME as theme, CSS as S2_CSS
37
+
38
+
39
+ # ════════════════════════════════════════════════════ UI callbacks ══
40
+ # (identical to v3.9 — backend logic untouched)
41
+ def ui_run_signals(tickers_text, force):
42
+ """Signals ONLY — pure rule engine, zero LLM, parallel downloads. Fast."""
43
+ import time
44
+ t0 = time.time()
45
+ tickers = [t for t in tickers_text.replace("\n", ",").split(",") if t.strip()]
46
+ df, details, summary, errors = signal_runner.run_signals(tickers or None,
47
+ force=bool(force))
48
+ if errors:
49
+ summary += " · some tickers skipped (data not ready yet — run again)"
50
+ automation.STATE["signals_df"] = df
51
+ automation.STATE["signals_details"] = details
52
+ automation.STATE["signals_summary"] = summary
53
+ choices = sorted(details.keys())
54
+ return (
55
+ df if df is not None else pd.DataFrame(),
56
+ f"{summary} · {time.time()-t0:.1f}s (rule engine only — no LLM in this path)",
57
+ gr.update(choices=choices, value=(choices[0] if choices else None)),
58
+ )
59
+
60
+
61
+ def ui_show_detail(ticker):
62
+ if not ticker:
63
+ return "Select a ticker after running the analysis."
64
+ raw = signal_runner.stock_raw_read(ticker)
65
+ if not raw:
66
+ return "No data for this ticker yet — run the analysis first."
67
+ return f"**Raw read:**\n\n{raw}"
68
+
69
+
70
+ def ui_explain_detail(ticker):
71
+ raw = signal_runner.stock_raw_read(ticker or "")
72
+ if not raw:
73
+ yield "Run the analysis and select a ticker first."
74
+ return
75
+ # The full Chan ruling chain (Chinese) is kept BACKSTAGE in STATE and fed to
76
+ # the model alongside the English raw read, so the summary reflects the real
77
+ # multi-timeframe reasoning — but the chain is never shown and output is
78
+ # English only. (This restores the merged raw-read + ruling-chain logic.)
79
+ chain = automation.STATE.get("signals_details", {}).get(ticker or "", "")
80
+ chain_core = chain.split("日线买卖点逐项诊断")[0].strip()[:2000] if chain else ""
81
+ prompt = ("You are an equity analyst. Write a SHORT plain-English summary "
82
+ "(≤100 words) for a long-term holder of a US stock: the situation "
83
+ "today, whether to act or wait, and the key price levels.\n"
84
+ "Use the FACT LINE for the numbers, and the RULING CHAIN (a "
85
+ "Chinese multi-timeframe Chan-theory decision log) for the reasoning "
86
+ "— translate and synthesize it; output ENGLISH ONLY, no Chinese "
87
+ "characters, do not quote the log, no disclaimers.\n\n"
88
+ f"FACT LINE:\n{raw}")
89
+ if chain_core:
90
+ prompt += f"\n\nRULING CHAIN (translate & synthesize, don't quote):\n{chain_core}"
91
+ yield "🤖 _Summary Sub-Agent (Chan-Tuned Qwen3-1.7B · llama.cpp) is explaining…_"
92
+ final = ""
93
+ for acc in llm_local.chat_stream(prompt, max_tokens=260, temperature=0.2,
94
+ worker="translator"):
95
+ final = acc
96
+ yield "🤖 **AI Summary (Summary Sub-Agent · Chan-Tuned Qwen3-1.7B):**\n\n" + acc
97
+ # capture (raw read → narrative) as a fine-tuning pair (🎯 Well-Tuned)
98
+ try:
99
+ import finetune_data
100
+ finetune_data.record(raw, final)
101
+ except Exception:
102
+ pass
103
+
104
+
105
+ def ui_refresh_rotation():
106
+ """Tables only — instant. AI narrative is a separate on-demand button."""
107
+ d1, d5, d20, asof = rotation.build_rotation(force=True)
108
+ automation.STATE["rotation"] = (d1, d5, d20, asof)
109
+ raw = rotation.rotation_brief(d1, d5, d20)
110
+ automation.STATE["rotation_narrative"] = raw
111
+ return (rotation.fmt_table(d1), rotation.fmt_table(d5), rotation.fmt_table(d20),
112
+ f"Sector flows as of **{asof}**", f"**Raw read:**\n{raw}")
113
+
114
+
115
+ def ui_rotation_ai():
116
+ d1, d5, d20, asof = automation.STATE["rotation"]
117
+ if d1 is None:
118
+ yield "Refresh the rotation tables first."
119
+ return
120
+ brief = rotation.rotation_brief(d1, d5, d20)
121
+ prompt = ("You are a US equity market strategist. Based only on the sector flow "
122
+ "data below (SPDR ETF proxy: change% × dollar volume, plus RS vs SPY), "
123
+ "write a crisp brief (<150 words): 1) where capital is rotating INTO/OUT "
124
+ "OF; 2) do 1-day moves agree with the 5/20-day trend; 3) one watch item. "
125
+ "No disclaimers.\n\nDATA:\n" + brief[:2200])
126
+ yield ("🤖 _Narrator sub-agent (Qwen3-1.7B · llama.cpp) is reading the flow "
127
+ "tables first words in ~5-15s…_")
128
+ for acc in llm_local.chat_stream(prompt, max_tokens=340, worker="narrator"):
129
+ yield "🤖 **Narrator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
130
+
131
+
132
+ def ui_save_holdings(text):
133
+ saved = news_watch.save_holdings(text.replace("\n", ",").split(","))
134
+ return f"Saved {len(saved)} holding(s): {', '.join(saved) if saved else '—'}"
135
+
136
+
137
+ def ui_check_news():
138
+ last = ""
139
+ for md in news_watch.check_holdings_news_stream():
140
+ last = md
141
+ yield md
142
+ automation.STATE["news_md"] = last
143
+
144
+
145
+ def ui_research(ticker):
146
+ progress, report = "", ""
147
+ for progress, report in research_agent.run_research_stream(ticker):
148
+ yield progress, report, gr.update()
149
+ reports = research_agent.list_reports()
150
+ newest = reports[0] if reports else None
151
+ yield progress, report, gr.update(choices=reports, value=newest)
152
+
153
+
154
+ def ui_open_report(fname):
155
+ return research_agent.read_report(fname)
156
+
157
+
158
+ def ui_load_model(name):
159
+ return llm_local.load_model(name, worker="deep")
160
+
161
+
162
+ _SELFTEST = {"done": False, "result": ""}
163
+
164
+
165
+ def _publish_traces(repo_id: str):
166
+ """One-click: upload /data/traces to the Hub as a dataset, using the
167
+ HF_TOKEN secret. No command line needed."""
168
+ repo_id = (repo_id or "").strip()
169
+ if "/" not in repo_id:
170
+ return "⚠️ Enter a repo id like `username/dataset-name`."
171
+ token = (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
172
+ or "").strip()
173
+ if not token:
174
+ return ("⚠️ No `HF_TOKEN` secret found. Add it in Space → Settings → "
175
+ "Variables and secrets (a **write** token), then try again.")
176
+ try:
177
+ import paths
178
+ traces = [f for f in os.listdir(paths.TRACES_DIR) if f.endswith(".json")]
179
+ except OSError:
180
+ traces = []
181
+ if not traces:
182
+ return "⚠️ No traces yet run a few Auto Research reports first."
183
+ try:
184
+ from huggingface_hub import HfApi
185
+ api = HfApi(token=token)
186
+ api.create_repo(repo_id, repo_type="dataset", exist_ok=True)
187
+ # a small README so the dataset page explains itself
188
+ card = (
189
+ "---\nlicense: mit\ntags: [agent-trace, finance, chan-theory, llama-cpp]\n---\n\n"
190
+ "# Chan Compass — agent traces\n\n"
191
+ "JSON traces from the Chan Compass multi-agent research desk. Each file "
192
+ "is one ticker's run: the plan, every evidence-tool call and its result, "
193
+ "and each local sub-agent's request and response. Shared for the "
194
+ "Build Small hackathon (*Sharing is Caring*).\n\n"
195
+ "*Educational data not investment advice.*\n")
196
+ import tempfile
197
+ rp = os.path.join(tempfile.gettempdir(), "README.md")
198
+ with open(rp, "w", encoding="utf-8") as f:
199
+ f.write(card)
200
+ api.upload_file(path_or_fileobj=rp, path_in_repo="README.md",
201
+ repo_id=repo_id, repo_type="dataset")
202
+ api.upload_folder(folder_path=paths.TRACES_DIR, path_in_repo="traces",
203
+ repo_id=repo_id, repo_type="dataset")
204
+ url = f"https://huggingface.co/datasets/{repo_id}"
205
+ return (f"✅ Published **{len(traces)}** trace(s) to [{repo_id}]({url}). "
206
+ f"Put that link in your submission for the *Sharing is Caring* badge.")
207
+ except Exception as e:
208
+ return f"❌ Upload failed: {e}"
209
+
210
+
211
+ def _export_dataset():
212
+ n = finetune_data.count()
213
+ if n == 0:
214
+ return ("⚠️ **0 training pairs captured yet.** Pairs are saved only when "
215
+ "the **Signals AI summary** finishes with a model loaded. Steps: "
216
+ "1) Model tab — wait for the Summary sub-agent to show ✅; "
217
+ "2) Signals — Run analysis, pick a ticker, click **AI summary**, "
218
+ "let it finish; repeat a few times; 3) come back and Export.",
219
+ gr.update(visible=False))
220
+ path = finetune_data.export()
221
+ if not path:
222
+ return ("⚠️ Export failed to write the file (storage error). Try again.",
223
+ gr.update(visible=False))
224
+ # Copy to a folder under the app's working dir, which Gradio serves
225
+ # reliably (the /data bucket and /tmp are not in Gradio's allowed paths).
226
+ import shutil
227
+ served_dir = os.path.join(os.getcwd(), "exports")
228
+ os.makedirs(served_dir, exist_ok=True)
229
+ served = os.path.join(served_dir, os.path.basename(path))
230
+ try:
231
+ shutil.copy(path, served)
232
+ except OSError:
233
+ served = path
234
+ msg = (f"✅ Exported **{n}** captured pair(s). Download below, then follow "
235
+ f"`finetune/FINETUNE_GUIDE.md` to LoRA-tune Qwen3-1.7B and publish it.")
236
+ return msg, gr.update(value=served, visible=True)
237
+
238
+
239
+ def _run_selftest():
240
+ """Run the self-test now (used by both the auto-timer and the manual button)
241
+ and cache the verdict so the two never fight over the output box."""
242
+ out = llm_local.quick_test()
243
+ ok = "not loaded" not in out and "error" not in out.lower()
244
+ _SELFTEST["done"] = True
245
+ _SELFTEST["result"] = (("✅ **Every agent is OK now** — self-test passed:\n\n" + out)
246
+ if ok else ("⚠️ Self-test finished with issues:\n\n" + out))
247
+ return _SELFTEST["result"]
248
+
249
+
250
+ def _auto_selftest():
251
+ """Auto-runs ONCE the moment every sub-agent is loaded. After that it returns
252
+ the cached verdict unchanged, so it never overwrites a manual test result."""
253
+ if _SELFTEST["done"]:
254
+ return _SELFTEST["result"]
255
+ workers = llm_local.WORKERS
256
+ if not all(w["llm"] is not None for w in workers.values()):
257
+ ready = sum(1 for w in workers.values() if w["llm"] is not None)
258
+ return f"⏳ Loading sub-agents… {ready}/{len(workers)} ready (self-test will run automatically)."
259
+ return _run_selftest()
260
+
261
+
262
+ def _manual_selftest():
263
+ """Manual button: always re-runs and shows the fresh verdict."""
264
+ return _run_selftest()
265
+
266
+
267
+ # ════════════════════════════════════════════════════ layout ══
268
+ _GR_MAJOR = int(gr.__version__.split(".")[0])
269
+ _style_kw = {} if _GR_MAJOR >= 6 else {"theme": theme, "css": S2_CSS}
270
+
271
+ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
272
+ with gr.Column(elem_id="s2-app"):
273
+ gr.HTML("""
274
+ <div id="s2-hero">
275
+ <div class="mark">🧭</div>
276
+ <div>
277
+ <h1>Chan Compass <span>· US Markets</span></h1>
278
+ <p>Multi-timeframe 缠论 (Chan theory) engine — monthly → 1m nested-interval
279
+ confirmation · sector rotation · local sub-agent pool (llama.cpp).</p>
280
+ </div>
281
+ <div class="chips">
282
+ <span>🧠 Local · no cloud</span><span>🦙 llama.cpp</span>
283
+ <span>🤖 4 sub-agents</span><span>📊 Yahoo Finance</span>
284
+ <span>⏰ 18:10 ET</span><span>🎨 Spectrum 2</span>
285
+ </div>
286
+ </div>""")
287
+
288
+ # ─────────────────────────────────────── Signals ──
289
+ with gr.Tab("📈 Signals"):
290
+ gr.Markdown("STEP 1 · SCAN YOUR POOL", elem_classes=["s2-eyebrow"])
291
+ with gr.Group(elem_classes=["s2-bar"]):
292
+ with gr.Row():
293
+ tickers_in = gr.Textbox(value=", ".join(signal_runner.DEFAULT_POOL),
294
+ label="Ticker pool (comma separated)", scale=4)
295
+ force_cb = gr.Checkbox(value=False, label="Force fresh download", scale=1)
296
+ run_btn = gr.Button(" Run analysis", variant="primary", scale=1)
297
+ sig_summary = gr.Markdown(automation.STATE["signals_summary"])
298
+ sig_table = gr.Dataframe(label="Tomorrow's plan — long-hold mode (sorted: BUY → SELL → HOLD → WAIT)",
299
+ interactive=False, wrap=True)
300
+
301
+ gr.Markdown("STEP 2 · EXPLAIN ONE TICKER", elem_classes=["s2-eyebrow"])
302
+ gr.Markdown("Pick a ticker for a plain-English raw read, then let the Summary "
303
+ "sub-agent write an AI summary.", elem_classes=["s2-help"])
304
+ with gr.Group(elem_classes=["s2-bar"]):
305
+ with gr.Row():
306
+ detail_pick = gr.Dropdown(choices=[], label="Ticker", scale=2)
307
+ explain_btn = gr.Button("🤖 AI summary (local LLM)", variant="primary", scale=1)
308
+ detail_box = gr.Markdown(label="Raw read")
309
+ explain_box = gr.Markdown(elem_classes=["llm-out"])
310
+ with gr.Accordion("✉ Email this summary", open=False):
311
+ with gr.Row():
312
+ sig_email = gr.Textbox(label="Send to", scale=4,
313
+ placeholder="name@example.com (comma-separate for several)")
314
+ sig_email_btn = gr.Button("✉ Send Email", scale=1)
315
+ sig_email_status = gr.Markdown()
316
+
317
+ # ─────────────────────────────────────── Sector Rotation ──
318
+ with gr.Tab("🔄 Sector Rotation"):
319
+ gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500). "
320
+ "**Flow proxy = change % × dollar volume**; RS = return minus SPY. "
321
+ "True per-sector fund-flow feeds are paid data — this is the standard free proxy.",
322
+ elem_classes=["s2-help"])
323
+ with gr.Group(elem_classes=["s2-bar"]):
324
+ with gr.Row():
325
+ rot_btn = gr.Button("↻ Refresh rotation (instant)", variant="primary", scale=1)
326
+ rot_ai_btn = gr.Button("🤖 AI narrative (local LLM)", scale=1)
327
+ rot_asof = gr.Markdown("Press refresh (or Run analysis on the Signals tab).")
328
+ with gr.Row():
329
+ rot_1d = gr.Dataframe(label="1-Day (today's rotation)", interactive=False)
330
+ with gr.Row():
331
+ rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
332
+ rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
333
+ rot_ai = gr.Markdown(label="AI rotation narrative", elem_classes=["llm-out"])
334
+ with gr.Accordion("✉ Email this narrative", open=False):
335
+ with gr.Row():
336
+ rot_email = gr.Textbox(label="Send to", scale=4,
337
+ placeholder="name@example.com (comma-separate for several)")
338
+ rot_email_btn = gr.Button("✉ Send Email", scale=1)
339
+ rot_email_status = gr.Markdown()
340
+
341
+ # ─────────────────────────────────────── Watchlist News ──
342
+ with gr.Tab("📰 Watchlist News"):
343
+ gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
344
+ "News found AI brief below. No news → ticker is listed under *Quiet today*.",
345
+ elem_classes=["s2-help"])
346
+ with gr.Group(elem_classes=["s2-bar"]):
347
+ with gr.Row():
348
+ hold_in = gr.Textbox(value=", ".join(news_watch.load_holdings()),
349
+ label="My holdings (comma separated)", scale=3)
350
+ save_btn = gr.Button("💾 Save holdings", scale=1)
351
+ news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
352
+ hold_status = gr.Markdown()
353
+ news_out = gr.Markdown(elem_classes=["llm-out"])
354
+ with gr.Accordion("✉ Email this news brief", open=False):
355
+ with gr.Row():
356
+ news_email = gr.Textbox(label="Send to", scale=4,
357
+ placeholder="name@example.com (comma-separate for several)")
358
+ news_email_btn = gr.Button("✉ Send Email", scale=1)
359
+ news_email_status = gr.Markdown()
360
+
361
+ # ─────────────────────────────────────── Auto Research ──
362
+ with gr.Tab("🧪 Auto Research"):
363
+ gr.Markdown("**Multi-step research agent** (fully local): PLAN → 5 evidence tools "
364
+ "(fundamentals · quarterly financials · price action · **the Chan engine "
365
+ "itself** · news) → section-by-section analysis → report. Every step is "
366
+ "logged to a JSON **agent trace**. New pool tickers get a report "
367
+ "**auto-generated** by the daily pipeline.", elem_classes=["s2-help"])
368
+ with gr.Group(elem_classes=["s2-bar"]):
369
+ with gr.Row():
370
+ res_in = gr.Textbox(label="Ticker", placeholder="e.g. NVDA", scale=3)
371
+ res_btn = gr.Button("🤖 Run research agent", variant="primary", scale=1)
372
+ res_progress = gr.Markdown()
373
+ res_out = gr.Markdown(elem_classes=["llm-out"])
374
+ with gr.Accordion("✉ Email this report", open=False):
375
+ with gr.Row():
376
+ res_email = gr.Textbox(label="Send to", scale=4,
377
+ placeholder="name@example.com (comma-separate for several)")
378
+ res_email_btn = gr.Button("✉ Send Email", scale=1)
379
+ res_email_status = gr.Markdown()
380
+
381
+ gr.Markdown("REPORT LIBRARY", elem_classes=["s2-eyebrow"])
382
+ gr.Markdown("Auto + manual reports, stored on `/data`.", elem_classes=["s2-help"])
383
+ with gr.Group(elem_classes=["s2-bar"]):
384
+ with gr.Row():
385
+ rep_pick = gr.Dropdown(choices=research_agent.list_reports(),
386
+ label="Saved reports", scale=3)
387
+ rep_open = gr.Button("📂 Open report", scale=1)
388
+ rep_view = gr.Markdown()
389
+
390
+ # ─────────────────────────────────────── Automation ──
391
+ with gr.Tab("⏰ Automation"):
392
+ gr.Markdown(paths.storage_status())
393
+ auto_md = gr.Markdown(automation.schedule_info())
394
+ with gr.Group(elem_classes=["s2-bar"]):
395
+ with gr.Row():
396
+ auto_now = gr.Button("⚡ Run now", variant="primary")
397
+ auto_msg = gr.Markdown()
398
+ auto_log = gr.Textbox(lines=14, label="Pipeline log (live — updates every 2s)",
399
+ elem_id="detail-log")
400
+ traces_md = gr.Markdown(research_agent.list_traces())
401
+ gr.Markdown("SHARE AGENT TRACES", elem_classes=["s2-eyebrow"])
402
+ gr.Markdown("Publish every JSON agent trace in `/data/traces` as a Hugging Face "
403
+ "**dataset** (one click, uses your `HF_TOKEN` secret — no command line). "
404
+ "Earns *Sharing is Caring*.", elem_classes=["s2-help"])
405
+ with gr.Group(elem_classes=["s2-bar"]):
406
+ with gr.Row():
407
+ trace_repo = gr.Textbox(
408
+ value="ranranrunforit/chan-compass-agent-traces",
409
+ label="Dataset repo id", scale=3)
410
+ trace_pub = gr.Button("📡 Publish traces as dataset", variant="primary", scale=1)
411
+ trace_pub_status = gr.Markdown()
412
+ trace_pub.click(_publish_traces, trace_repo, trace_pub_status)
413
+ auto_log_timer = gr.Timer(2.0)
414
+
415
+ def _auto_tick():
416
+ log = "\n".join(automation.STATE["log"][-40:]) or "(no log yet)"
417
+ return log, research_agent.list_traces()
418
+
419
+ auto_log_timer.tick(_auto_tick, None, [auto_log, traces_md])
420
+
421
+ # ─────────────────────────────────────── Model ──
422
+ with gr.Tab("🧠 Model"):
423
+ gr.Markdown("All AI runs **locally** through **llama.cpp** with Qwen3 GGUF weights — "
424
+ "every option is far below the 32B cap, and nothing leaves the machine. "
425
+ "**First load installs the runtime + downloads the GGUF (one-time, usually "
426
+ "1–3 min; worst case ~15 min if it compiles).** Signals/rotation/news never "
427
+ "depend on it.", elem_classes=["s2-help"])
428
+ gr.Markdown("**Sub-agent pool:** Summary (Chan-Tuned Qwen3-1.7B, fixed) handles "
429
+ "Explain / rotation narrative / news briefs; `deep` Analyst writes research "
430
+ "reports. Each has its own lock — they run in parallel.", elem_classes=["s2-help"])
431
+ with gr.Group(elem_classes=["s2-bar"]):
432
+ model_pick = gr.Radio(choices=list(llm_local.MODEL_ZOO.keys()),
433
+ value=llm_local.DEFAULT_MODEL, label="Analyst (deep) model")
434
+ with gr.Row():
435
+ load_btn = gr.Button("⬇ Load model", variant="primary", scale=1)
436
+ test_btn = gr.Button("⚡ Test sub-agents now", scale=1)
437
+ model_status = gr.Markdown(llm_local.status())
438
+ model_test_out = gr.Markdown()
439
+ model_timer = gr.Timer(2.0)
440
+ model_timer.tick(lambda: llm_local.status(), None, model_status)
441
+ autotest_timer = gr.Timer(3.0)
442
+ autotest_timer.tick(_auto_selftest, None, model_test_out)
443
+
444
+ gr.Markdown("🎯 FINE-TUNING DATASET", elem_classes=["s2-eyebrow"])
445
+ gr.Markdown("Every Signals **AI summary** you run is saved as a (raw read → narrative) "
446
+ "training pair on `/data`. Capture a few hundred, export the JSONL, then "
447
+ "follow `FINETUNE_GUIDE.md` to LoRA-tune Qwen3-1.7B and publish it.",
448
+ elem_classes=["s2-help"])
449
+ ft_status = gr.Markdown(finetune_data.status_line())
450
+ with gr.Group(elem_classes=["s2-bar"]):
451
+ ft_export = gr.Button("⬇ Export dataset (JSONL)", variant="primary")
452
+ ft_out = gr.Markdown()
453
+ ft_file = gr.File(label="Download training data", visible=False)
454
+ ft_timer = gr.Timer(3.0)
455
+ ft_timer.tick(lambda: finetune_data.status_line(), None, ft_status)
456
+ ft_export.click(_export_dataset, None, [ft_out, ft_file])
457
+
458
+ gr.Markdown("Chan Compass · educational tool, not investment advice · "
459
+ "data: Yahoo Finance · design language: Adobe Spectrum 2",
460
+ elem_classes=["s2-footnote"])
461
+
462
+ # ─────────────────────────────────────── wiring (unchanged) ──
463
+ run_btn.click(ui_run_signals, [tickers_in, force_cb],
464
+ [sig_table, sig_summary, detail_pick])
465
+ detail_pick.change(ui_show_detail, detail_pick, detail_box)
466
+ explain_btn.click(ui_explain_detail, detail_pick, explain_box)
467
+ rot_btn.click(ui_refresh_rotation, None, [rot_1d, rot_5d, rot_20d, rot_asof, rot_ai])
468
+ rot_ai_btn.click(ui_rotation_ai, None, rot_ai)
469
+ save_btn.click(ui_save_holdings, hold_in, hold_status)
470
+ news_btn.click(ui_check_news, None, news_out)
471
+ res_btn.click(ui_research, res_in, [res_progress, res_out, rep_pick])
472
+ sig_email_btn.click(lambda body, to: emailer.send_result(body, to, "Signals summary"),
473
+ [explain_box, sig_email], sig_email_status)
474
+ rot_email_btn.click(lambda body, to: emailer.send_result(body, to, "Sector rotation"),
475
+ [rot_ai, rot_email], rot_email_status)
476
+ news_email_btn.click(lambda body, to: emailer.send_result(body, to, "Watchlist news"),
477
+ [news_out, news_email], news_email_status)
478
+ res_email_btn.click(lambda body, to: emailer.send_result(body, to, "Research report"),
479
+ [res_out, res_email], res_email_status)
480
+ rep_open.click(ui_open_report, rep_pick, rep_view)
481
+ auto_now.click(lambda: automation.run_pipeline(force=True), None, auto_msg)
482
+ load_btn.click(ui_load_model, model_pick, model_status)
483
+ test_btn.click(_manual_selftest, None, model_test_out)
484
+
485
+
486
+ # ════════════════════════════════════════════════════ boot ══
487
+ automation.start_scheduler()
488
+
489
+
490
+ def _auto_load_model():
491
+ try:
492
+ automation._log("Auto-loading sub-agents (llama.cpp)…")
493
+ llm_local.auto_load_all()
494
+ except Exception as e:
495
+ automation._log(f"Sub-agent auto-load failed: {e}")
496
+
497
+
498
+ try:
499
+ import paths as _paths
500
+ automation._log(_paths.storage_status())
501
+ except Exception:
502
+ pass
503
+ if os.environ.get("AUTO_LOAD_MODEL", "1") == "1":
504
+ threading.Thread(target=_auto_load_model, daemon=True).start()
505
+
506
+
507
+ if __name__ == "__main__":
508
+ import paths as _p
509
+ _allowed = [os.path.join(os.getcwd(), "exports"), _p.DATASET_DIR]
510
+ os.makedirs(_allowed[0], exist_ok=True)
511
+ if _GR_MAJOR >= 6:
512
+ demo.launch(theme=theme, css=S2_CSS, allowed_paths=_allowed)
513
+ else:
514
+ demo.launch(allowed_paths=_allowed)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
chan_enhance.py CHANGED
@@ -1,123 +1,147 @@
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
 
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
+ # LLM narrative is generated ON DEMAND from the Rotation tab button —
73
+ # the pipeline itself never waits on the model.
74
+ STATE["rotation_narrative"] = rotation.rotation_brief(d1, d5, d20)
75
+ _log(f"Sector rotation rebuilt (as of {asof}).")
76
+
77
+ STATE["news_md"] = news_watch.check_holdings_news()
78
+ _log("Holdings news checked.")
79
+
80
+ # ── Feature 4: auto research report for every NEW ticker in the pool ──
81
+ try:
82
+ import json
83
+ import os
84
+ import paths
85
+ import research_agent
86
+ known_path = os.path.join(paths.OUTPUT_DIR, "known_tickers.json")
87
+ try:
88
+ with open(known_path, encoding="utf-8") as f:
89
+ known = set(json.load(f))
90
+ except (OSError, ValueError):
91
+ known = set()
92
+ current = set(df["Ticker"].tolist()) if df is not None and len(df) else set()
93
+ new_tickers = sorted(current - known)
94
+ generated = set()
95
+ for t in new_tickers[:5]: # safety cap per run
96
+ _log(f"New ticker {t} → auto-generating research report…")
97
+ report, trace = research_agent.run_research(t, auto=True)
98
+ if report:
99
+ generated.add(t)
100
+ _log(f"Report for {t} done{' (+trace)' if trace else ''}.")
101
+ else:
102
+ _log(f"Report for {t} postponed (sub-agents still loading) "
103
+ f"will retry on the next run.")
104
+ done_set = known | (current - (set(new_tickers) - generated))
105
+ if current:
106
+ with open(known_path, "w", encoding="utf-8") as f:
107
+ json.dump(sorted(done_set), f)
108
+ except Exception as e:
109
+ _log(f"Auto-research skipped: {e}")
110
+
111
+ STATE["last_run"] = dt.datetime.now(NY)
112
+ _log("Pipeline finished.")
113
+ return f"Done. {summary}"
114
+ except Exception as e:
115
+ traceback.print_exc()
116
+ _log(f"Pipeline error: {e}")
117
+ return f"Pipeline error: {e}"
118
+ finally:
119
+ STATE["running"] = False
120
+
121
+
122
+ def start_scheduler():
123
+ """Cron: Mon–Fri 18:10 America/New_York."""
124
+ try:
125
+ from apscheduler.schedulers.background import BackgroundScheduler
126
+ from apscheduler.triggers.cron import CronTrigger
127
+ except Exception as e:
128
+ _log(f"APScheduler unavailable: {e}")
129
  return None
130
+ sched = BackgroundScheduler(timezone=NY)
131
+ sched.add_job(run_pipeline, CronTrigger(day_of_week="mon-fri",
132
+ hour=RUN_HOUR, minute=RUN_MINUTE),
133
+ id="daily_pipeline", max_instances=1, coalesce=True)
134
+ sched.start()
135
+ _log(f"Scheduler armed: Mon–Fri {RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York.")
136
+ return sched
137
+
138
+
139
+ def schedule_info() -> str:
140
+ now = dt.datetime.now(NY)
141
+ last = STATE["last_run"].strftime("%Y-%m-%d %H:%M ET") if STATE["last_run"] else "never"
142
+ return (f"**Schedule:** Mon–Fri **{RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York** "
143
+ f"(market closes 16:00 ET; by 18:10 the official daily bar has settled — "
144
+ f"that's 07:10 next morning Beijing time).\n\n"
145
+ f"**Now (ET):** {now.strftime('%Y-%m-%d %H:%M')} · **Last run:** {last}\n\n"
146
+ f"⚠️ On free Space hardware the app sleeps when idle and the timer can't fire; "
147
+ f"use **Run now**, or upgrade to always-on hardware for unattended updates.")
 
 
 
 
 
 
 
 
 
 
 
 
 
chan_glue.py CHANGED
@@ -1,70 +1,1451 @@
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
- chan_engine.PIVOT_MAX_EXTEND_SEGS = chan_engine.PIVOT_MAX_EXTEND_SEGS # no-op, keeps linters calm
27
-
28
- # Re-exports for app code
29
- ChanAnalyzer = chan_engine.ChanAnalyzer
30
- Signal = chan_engine.Signal
31
- MultiLevelChan = chan_multilevel.MultiLevelChan
32
- MultiLevelSignal = chan_multilevel.MultiLevelSignal
33
- resample_weekly = chan_multilevel.resample_weekly
34
- resample_monthly = chan_multilevel.resample_monthly
35
- set_analyzer_factory = chan_multilevel.set_analyzer_factory
36
-
37
- # ── cached analyzer factory ──────────────────────────────────────────────
38
- _CACHE: "OrderedDict[str, chan_engine.ChanAnalyzer]" = OrderedDict()
39
- _CACHE_MAX = 64
40
-
41
-
42
- def _df_key(level: str, df: pd.DataFrame) -> str:
43
- n = len(df)
44
- if n == 0:
45
- return f"{level}-empty"
46
- last = str(df['date'].iloc[-1])
47
- first = str(df['date'].iloc[0])
48
- tail_close = float(df['close'].iloc[-1])
49
- raw = f"{level}|{n}|{first}|{last}|{tail_close:.6f}"
50
- return hashlib.md5(raw.encode()).hexdigest()
51
-
52
-
53
- def cached_analyzer(level: str, df: pd.DataFrame):
54
- key = _df_key(level, df)
55
- if key in _CACHE:
56
- _CACHE.move_to_end(key)
57
- return _CACHE[key]
58
- an = chan_engine.ChanAnalyzer(df.reset_index(drop=True))
59
- _CACHE[key] = an
60
- while len(_CACHE) > _CACHE_MAX:
61
- _CACHE.popitem(last=False)
62
- return an
63
-
64
-
65
- def install():
66
- """Install the cached analyzer factory into MultiLevelChan."""
67
- set_analyzer_factory(cached_analyzer)
68
-
69
-
70
- install()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_multilevel.py CHANGED
@@ -1,884 +1,123 @@
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}), 观望')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
data_us.py CHANGED
@@ -1,147 +1,70 @@
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
- import paths
29
-
30
- CACHE_DIR = os.environ.get("CHAN_CACHE_DIR", paths.CACHE_DIR)
31
-
32
- LEVELS = {
33
- # level: (yfinance interval, period) — Yahoo's max history per interval
34
- "d": ("1d", "10y"),
35
- "60m": ("60m", "730d"),
36
- "30m": ("30m", "60d"),
37
- "15m": ("15m", "60d"),
38
- "5m": ("5m", "60d"),
39
- "1m": ("1m", "7d"), # only 7 days available; short but usable for the
40
- # finest nested-interval confirmation when present
41
- }
42
-
43
- _STALE_SECONDS = {"d": 12 * 3600, "60m": 2 * 3600, "30m": 2 * 3600,
44
- "15m": 2 * 3600, "5m": 2 * 3600, "1m": 1800}
45
-
46
-
47
- def _cache_path(ticker: str, level: str) -> str:
48
- d = os.path.join(CACHE_DIR, ticker.upper().replace("/", "_"))
49
- os.makedirs(d, exist_ok=True)
50
- return os.path.join(d, f"{level}.parquet")
51
-
52
-
53
- def _normalize(df: pd.DataFrame) -> pd.DataFrame:
54
- """yfinance frame → engine schema (date/open/close/high/low/volume/amount)."""
55
- if df is None or len(df) == 0:
56
- return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
57
- d = df.copy()
58
- if isinstance(d.columns, pd.MultiIndex): # yf>=0.2 returns MultiIndex sometimes
59
- d.columns = [c[0] if isinstance(c, tuple) else c for c in d.columns]
60
- d = d.reset_index()
61
- # index column may be 'Date' or 'Datetime'
62
- for cand in ("Datetime", "Date", "index"):
63
- if cand in d.columns:
64
- d = d.rename(columns={cand: "date"})
65
- break
66
- d.columns = [str(c).lower() for c in d.columns]
67
- keep = {"date", "open", "high", "low", "close", "volume"}
68
- d = d[[c for c in d.columns if c in keep]]
69
- d["date"] = pd.to_datetime(d["date"])
70
- # strip timezone so comparisons with naive Timestamps in the engine work
71
- try:
72
- d["date"] = d["date"].dt.tz_localize(None)
73
- except (TypeError, AttributeError):
74
- pass
75
- d = d.dropna(subset=["open", "high", "low", "close"])
76
- d = d.sort_values("date").reset_index(drop=True)
77
- d["amount"] = d["close"] * d.get("volume", 0)
78
- return d[["date", "open", "close", "high", "low", "volume", "amount"]]
79
-
80
-
81
- def load_level(ticker: str, level: str, force: bool = False) -> pd.DataFrame:
82
- """Load one level for a ticker, using parquet cache when fresh."""
83
- assert level in LEVELS, f"unknown level {level}"
84
- path = _cache_path(ticker, level)
85
- if not force and os.path.exists(path):
86
- age = time.time() - os.path.getmtime(path)
87
- if age < _STALE_SECONDS[level]:
88
- try:
89
- return pd.read_parquet(path)
90
- except Exception:
91
- pass
92
- try:
93
- import yfinance as yf
94
- interval, period = LEVELS[level]
95
- raw = yf.Ticker(ticker).history(period=period, interval=interval,
96
- auto_adjust=True, actions=False)
97
- df = _normalize(raw)
98
- if len(df):
99
- df.to_parquet(path, index=False)
100
- return df
101
- except Exception:
102
- traceback.print_exc()
103
- # network failed → fall back to stale cache if any
104
- if os.path.exists(path):
105
- try:
106
- return pd.read_parquet(path)
107
- except Exception:
108
- pass
109
- return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
110
-
111
-
112
- # Full nested-interval set (区间套): the more sub-levels confirm, the more
113
- # precise the buy/sell point. We fetch the deepest Yahoo allows. 1m has only
114
- # 7 days of history — included when present, skipped gracefully otherwise.
115
- # Downloads are parallel + cached, so the extra levels cost little wall-time.
116
- FULL_LEVELS = ("d", "60m", "30m", "15m", "5m", "1m")
117
- FAST_LEVELS = FULL_LEVELS # default everywhere; alias kept for older callers
118
-
119
-
120
- def load_levels(ticker: str, levels=FAST_LEVELS, force: bool = False) -> dict:
121
- return {lvl: load_level(ticker, lvl, force=force) for lvl in levels}
122
-
123
-
124
- def load_all_levels(ticker: str, force: bool = False) -> dict:
125
- """Return {'d':…, '60m':…, '30m':…, '15m':…, '5m':…} (1m intentionally absent)."""
126
- return {lvl: load_level(ticker, lvl, force=force) for lvl in LEVELS}
127
-
128
-
129
- def prefetch(tickers, levels=FAST_LEVELS, force: bool = False, workers: int = 5,
130
- budget_s: int = 45):
131
- """Download all (ticker, level) pairs in parallel with a hard time budget.
132
- Yahoo rate-limits datacenter IPs; without a budget one throttled request
133
- could hang the whole Run-analysis click. Whatever isn't fetched in time is
134
- skipped — the engine analyzes from daily/cached data and the next run
135
- picks up the rest."""
136
- from concurrent.futures import ThreadPoolExecutor, wait
137
- jobs = [(t, lvl) for t in tickers for lvl in levels]
138
- ex = ThreadPoolExecutor(max_workers=workers)
139
- futs = [ex.submit(load_level, t, lvl, force) for t, lvl in jobs]
140
- done, not_done = wait(futs, timeout=budget_s)
141
- ex.shutdown(wait=False, cancel_futures=True)
142
- return len(done), len(not_done)
143
-
144
-
145
- def last_daily_date(ticker: str):
146
- df = load_level(ticker, "d")
147
- return None if df.empty else pd.Timestamp(df["date"].iloc[-1])
 
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
+ chan_engine.PIVOT_MAX_EXTEND_SEGS = chan_engine.PIVOT_MAX_EXTEND_SEGS # no-op, keeps linters calm
27
+
28
+ # Re-exports for app code
29
+ ChanAnalyzer = chan_engine.ChanAnalyzer
30
+ Signal = chan_engine.Signal
31
+ MultiLevelChan = chan_multilevel.MultiLevelChan
32
+ MultiLevelSignal = chan_multilevel.MultiLevelSignal
33
+ resample_weekly = chan_multilevel.resample_weekly
34
+ resample_monthly = chan_multilevel.resample_monthly
35
+ set_analyzer_factory = chan_multilevel.set_analyzer_factory
36
+
37
+ # ── cached analyzer factory ──────────────────────────────────────────────
38
+ _CACHE: "OrderedDict[str, chan_engine.ChanAnalyzer]" = OrderedDict()
39
+ _CACHE_MAX = 64
40
+
41
+
42
+ def _df_key(level: str, df: pd.DataFrame) -> str:
43
+ n = len(df)
44
+ if n == 0:
45
+ return f"{level}-empty"
46
+ last = str(df['date'].iloc[-1])
47
+ first = str(df['date'].iloc[0])
48
+ tail_close = float(df['close'].iloc[-1])
49
+ raw = f"{level}|{n}|{first}|{last}|{tail_close:.6f}"
50
+ return hashlib.md5(raw.encode()).hexdigest()
51
+
52
+
53
+ def cached_analyzer(level: str, df: pd.DataFrame):
54
+ key = _df_key(level, df)
55
+ if key in _CACHE:
56
+ _CACHE.move_to_end(key)
57
+ return _CACHE[key]
58
+ an = chan_engine.ChanAnalyzer(df.reset_index(drop=True))
59
+ _CACHE[key] = an
60
+ while len(_CACHE) > _CACHE_MAX:
61
+ _CACHE.popitem(last=False)
62
+ return an
63
+
64
+
65
+ def install():
66
+ """Install the cached analyzer factory into MultiLevelChan."""
67
+ set_analyzer_factory(cached_analyzer)
68
+
69
+
70
+ install()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
elevation.css ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ BASE — Chan Compass · Spectrum 2
3
+ Light, opinionated element defaults. Body uses the canvas
4
+ background and UI type. Helper classes for the few primitives
5
+ that aren't worth a React component.
6
+ ============================================================ */
7
+
8
+ *, *::before, *::after { box-sizing: border-box; }
9
+
10
+ body {
11
+ margin: 0;
12
+ background: var(--canvas);
13
+ color: var(--text-body);
14
+ font: var(--type-body);
15
+ -webkit-font-smoothing: antialiased;
16
+ text-rendering: optimizeLegibility;
17
+ }
18
+
19
+ h1, h2, h3, h4 {
20
+ margin: 0;
21
+ color: var(--text-heading);
22
+ letter-spacing: var(--letter-spacing-heading);
23
+ text-wrap: balance;
24
+ }
25
+ h1 { font: var(--type-h1); }
26
+ h2 { font: var(--type-h2); }
27
+ h3 { font: var(--type-h3); letter-spacing: 0; }
28
+ h4 { font: var(--type-h4); letter-spacing: 0; }
29
+
30
+ p { margin: 0; text-wrap: pretty; }
31
+
32
+ a { color: var(--accent-text); text-decoration: none; }
33
+ a:hover { text-decoration: underline; text-underline-offset: 2px; }
34
+
35
+ code, kbd, samp { font-family: var(--font-code); font-size: 0.92em; }
36
+
37
+ ::selection { background: var(--accent-subtle); }
38
+
39
+ :focus-visible {
40
+ outline: var(--focus-ring-width) solid var(--focus-ring);
41
+ outline-offset: var(--focus-ring-offset);
42
+ border-radius: var(--radius-sm);
43
+ }
44
+
45
+ /* ---- Eyebrow / overline ---- */
46
+ .s2-eyebrow {
47
+ font: var(--font-weight-bold) var(--font-size-50)/1.2 var(--font-sans);
48
+ letter-spacing: var(--letter-spacing-caps);
49
+ text-transform: uppercase;
50
+ color: var(--text-muted);
51
+ }
52
+
53
+ /* ---- Hairline divider ---- */
54
+ .s2-divider {
55
+ border: none;
56
+ border-top: var(--border-width-100) solid var(--border-hairline);
57
+ margin: var(--space-300) 0;
58
+ }
emailer.py CHANGED
@@ -1,288 +1,884 @@
1
- """
2
- emailer.py — send a tab's AI result to any address (English UI + content).
 
3
 
4
- Adapted from the user's send_predict_email.py: same multi-domain SMTP
5
- auto-detection, plain+HTML multipart, but English subjects/labels and a generic
6
- `send_result()` the Gradio tabs call.
 
 
 
 
 
 
 
7
 
8
- Sender credentials live here (a Gmail App Password). To change the sender, edit
9
- EMAIL_SENDER / EMAIL_PASSWORD. You can also override them with the environment
10
- variables CHAN_EMAIL_SENDER / CHAN_EMAIL_PASSWORD on the Space (recommended:
11
- store the App Password as a Space *secret* rather than in code).
 
 
 
 
 
 
 
 
 
12
  """
13
  from __future__ import annotations
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
- import os
16
- import json
17
- import html
18
- import logging
19
- import smtplib
20
- import urllib.request
21
- import urllib.error
22
- import re
23
- from datetime import datetime
24
- from email.mime.text import MIMEText
25
- from email.mime.multipart import MIMEMultipart
26
- from email.header import Header
27
- from email.utils import formataddr
28
-
29
- logger = logging.getLogger("chan_emailer")
30
-
31
- # ── Transport 1 (preferred on HF): Resend HTTPS API on port 443 ──
32
- # HF Spaces block outbound SMTP ports (465/587) → SMTP fails with
33
- # "Network is unreachable". The Resend REST API uses plain HTTPS, which Spaces
34
- # allow. Set a Space secret RESEND_API_KEY to enable it. Free tier ≈ 100/day.
35
- # Until you verify your own domain, Resend only lets you send FROM
36
- # "onboarding@resend.dev" — that's the default sender below.
37
- RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
38
- RESEND_FROM = os.environ.get("RESEND_FROM", "Chan Compass <onboarding@resend.dev>")
39
-
40
- # ── Transport 2 (fallback, works off-HF): classic SMTP ──
41
- EMAIL_SENDER = os.environ.get("CHAN_EMAIL_SENDER", "")
42
- EMAIL_PASSWORD = os.environ.get("CHAN_EMAIL_PASSWORD", "")
43
-
44
- SMTP_CONFIGS = {
45
- "gmail.com": {"server": "smtp.gmail.com", "port": 465, "ssl": True},
46
- "qq.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
47
- "163.com": {"server": "smtp.163.com", "port": 465, "ssl": True},
48
- "126.com": {"server": "smtp.126.com", "port": 465, "ssl": True},
49
- "outlook.com": {"server": "smtp.office365.com", "port": 587, "ssl": False},
50
- "hotmail.com": {"server": "smtp.office365.com", "port": 587, "ssl": False},
51
- "foxmail.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
52
- "sina.com": {"server": "smtp.sina.com", "port": 465, "ssl": True},
53
- "aliyun.com": {"server": "smtp.aliyun.com", "port": 465, "ssl": True},
54
- }
55
-
56
- _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
57
-
58
-
59
- def _sender_address(sender: str) -> str:
60
- return formataddr((str(Header("Chan Compass", "utf-8")), sender))
61
-
62
-
63
- def _md_to_plain(text: str) -> str:
64
- """Markdown -> readable plain text (for the text/plain MIME part and the
65
- Resend `text` field). Strips #/**/| noise so text-only clients look clean."""
66
- lines, out = text.split("\n"), []
67
- i, n = 0, len(lines)
68
- while i < n:
69
- line = lines[i]
70
- if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", lines[i + 1]):
71
- header = [c.strip() for c in line.strip().strip("|").split("|")]
72
- i += 2
73
- while i < n and "|" in lines[i]:
74
- cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
75
- if len(header) == 2 and len(cells) == 2:
76
- out.append(f" {cells[0]}: {cells[1]}")
77
- else:
78
- out.append(" " + " | ".join(cells))
79
- i += 1
80
- out.append("")
81
- continue
82
- m = re.match(r"^(#{1,4})\s+(.*)$", line)
83
- if m:
84
- title = m.group(2).strip()
85
- out.append("")
86
- out.append(title.upper() if len(m.group(1)) <= 2 else title)
87
- out.append("-" * min(len(title), 60))
88
- i += 1
89
- continue
90
- if line.strip() in ("---", "***", "___"):
91
- out.append("-" * 40)
92
- i += 1
93
- continue
94
- s = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
95
- s = re.sub(r"`(.+?)`", r"\1", s)
96
- s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", r"\1 (\2)", s)
97
- s = re.sub(r"^_(.+)_$", r"\1", s.strip())
98
- out.append(s)
99
- i += 1
100
- txt = "\n".join(out)
101
- return re.sub(r"\n{3,}", "\n\n", txt).strip()
102
-
103
-
104
- def _inline_md(s: str) -> str:
105
- s = html.escape(s)
106
- s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
107
- s = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", s)
108
- s = re.sub(r"`(.+?)`", r"<code>\1</code>", s)
109
- s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)",
110
- r'<a href="\2">\1</a>', s)
111
- return s
112
-
113
-
114
- def _md_to_html(text: str) -> str:
115
- """Minimal but correct Markdown → HTML (headings, tables, lists, bold,
116
- code, links). The reports are Markdown, so emailing them as preformatted
117
- text showed raw `#`/`|` symbols — this renders them properly."""
118
- lines = text.split("\n")
119
- out, i, n = [], 0, len(lines)
120
- while i < n:
121
- line = lines[i]
122
- # table block: a header row of pipes followed by a |---| separator
123
- if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", lines[i + 1]):
124
- header = [c.strip() for c in line.strip().strip("|").split("|")]
125
- i += 2
126
- rows = []
127
- while i < n and "|" in lines[i]:
128
- rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
129
- i += 1
130
- th = "".join(f"<th style='text-align:left;padding:6px 10px;"
131
- f"border-bottom:2px solid #ddd'>{_inline_md(c)}</th>" for c in header)
132
- trs = ""
133
- for r in rows:
134
- tds = "".join(f"<td style='padding:6px 10px;border-bottom:1px solid #eee'>"
135
- f"{_inline_md(c)}</td>" for c in r)
136
- trs += f"<tr>{tds}</tr>"
137
- out.append(f"<table style='border-collapse:collapse;margin:10px 0;"
138
- f"font-size:13px'><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table>")
139
- continue
140
- m = re.match(r"^(#{1,4})\s+(.*)$", line)
141
- if m:
142
- lvl = len(m.group(1))
143
- size = {1: 20, 2: 16, 3: 14, 4: 13}[lvl]
144
- out.append(f"<h{lvl} style='font-size:{size}px;margin:14px 0 6px;"
145
- f"color:#0265DC'>{_inline_md(m.group(2))}</h{lvl}>")
146
- i += 1
147
- continue
148
- if re.match(r"^\s*[-*]\s+", line):
149
- items = []
150
- while i < n and re.match(r"^\s*[-*]\s+", lines[i]):
151
- item_text = re.sub(r"^\s*[-*]\s+", "", lines[i])
152
- items.append(f"<li>{_inline_md(item_text)}</li>")
153
- i += 1
154
- out.append(f"<ul style='margin:6px 0 6px 18px'>{''.join(items)}</ul>")
155
- continue
156
- if line.strip() in ("---", "***", "___"):
157
- out.append("<hr style='border:none;border-top:1px solid #e3e6ea;margin:12px 0'>")
158
- i += 1
159
- continue
160
- if line.strip() == "":
161
- out.append("<div style='height:6px'></div>")
162
- i += 1
163
- continue
164
- out.append(f"<p style='margin:4px 0'>{_inline_md(line)}</p>")
165
- i += 1
166
- body = "".join(out)
167
- return (
168
- '<html><body style="margin:0;padding:18px;background:#f6f7f9;">'
169
- '<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;'
170
- 'font-size:14px;line-height:1.55;color:#1a1a1a;background:#ffffff;'
171
- 'padding:22px 26px;border-radius:12px;border:1px solid #e3e6ea;'
172
- 'max-width:780px;margin:0 auto;">'
173
- f'{body}'
174
- '<p style="font-size:11px;color:#8a8a8a;margin:16px 0 0;border-top:'
175
- '1px solid #eee;padding-top:10px;">Sent by Chan Compass · educational '
176
- 'tool, not investment advice.</p>'
177
- '</div></body></html>'
178
- )
179
-
180
-
181
- def _parse_recipients(raw: str) -> list:
182
- parts = re.split(r"[,;\s]+", (raw or "").strip())
183
- return [p for p in parts if _EMAIL_RE.match(p)]
184
-
185
-
186
- def _close(server):
187
- if server is not None:
188
  try:
189
- server.quit()
 
190
  except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  try:
192
- server.close()
 
 
 
 
 
 
193
  except Exception:
194
  pass
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
- def _send_via_resend(recipients: list, subject: str, content: str) -> str:
198
- """HTTPS POST to Resend works on HF (port 443). Returns '' on success."""
199
- payload = json.dumps({
200
- "from": RESEND_FROM,
201
- "to": recipients,
202
- "subject": subject,
203
- "text": _md_to_plain(content),
204
- "html": _md_to_html(content),
205
- }).encode("utf-8")
206
- req = urllib.request.Request(
207
- "https://api.resend.com/emails", data=payload, method="POST",
208
- headers={"Authorization": f"Bearer {RESEND_API_KEY}",
209
- "Content-Type": "application/json",
210
- # Resend/Cloudflare reject requests with no User-Agent
211
- # (403, error code 1010) before they reach the API.
212
- "User-Agent": "chan-compass/1.0 (+https://huggingface.co/spaces)"})
213
- try:
214
- with urllib.request.urlopen(req, timeout=30) as resp:
215
- body = resp.read().decode("utf-8", "ignore")
216
- if '"id"' in body:
217
- return ""
218
- return f"Resend API responded without an id: {body[:200]}"
219
- except urllib.error.HTTPError as e:
220
- detail = e.read().decode("utf-8", "ignore")[:200]
221
- return f"Resend HTTP {e.code}: {detail}"
222
- except Exception as e:
223
- return f"Resend request failed: {e}"
224
-
225
-
226
- def _send_via_smtp(recipients: list, subject: str, content: str) -> str:
227
- """Classic SMTP — works locally / off-HF. Returns '' on success."""
228
- if not EMAIL_SENDER or not EMAIL_PASSWORD:
229
- return "SMTP sender not configured."
230
- msg = MIMEMultipart("alternative")
231
- msg["Subject"] = Header(subject, "utf-8")
232
- msg["From"] = _sender_address(EMAIL_SENDER)
233
- msg["To"] = ", ".join(recipients)
234
- msg.attach(MIMEText(_md_to_plain(content), "plain", "utf-8"))
235
- msg.attach(MIMEText(_md_to_html(content), "html", "utf-8"))
236
- domain = EMAIL_SENDER.split("@")[-1].lower()
237
- sc = SMTP_CONFIGS.get(domain, {"server": f"smtp.{domain}", "port": 465, "ssl": True})
238
- server = None
239
- try:
240
- if sc["ssl"]:
241
- server = smtplib.SMTP_SSL(sc["server"], sc["port"], timeout=20)
242
  else:
243
- server = smtplib.SMTP(sc["server"], sc["port"], timeout=20)
244
- server.starttls()
245
- server.login(EMAIL_SENDER, EMAIL_PASSWORD)
246
- server.send_message(msg)
247
- return ""
248
- except smtplib.SMTPAuthenticationError:
249
- return "SMTP authentication failed (check sender / App Password)."
250
- except OSError as e:
251
- return f"SMTP network error: {e}"
252
- except Exception as e:
253
- return f"SMTP failed: {e}"
254
- finally:
255
- _close(server)
256
-
257
-
258
- def send_result(content: str, recipients_raw: str, subject_tag: str) -> str:
259
- """Send `content` to the address(es). Prefers the Resend HTTPS API (works on
260
- HF Spaces); falls back to SMTP. English status string for the UI."""
261
- if not content or not content.strip():
262
- return "⚠️ Nothing to send yet — generate a result first."
263
- if content.strip().startswith(("⏳", "🤖 _", "Run ", "Enter ", "Select ")):
264
- return "⚠️ Wait for the AI result to finish, then send."
265
- recipients = _parse_recipients(recipients_raw)
266
- if not recipients:
267
- return "⚠️ Enter a valid email address (e.g. name@example.com)."
268
-
269
- subject = (f"Chan Compass · {subject_tag} · "
270
- f"{datetime.now().strftime('%Y-%m-%d %H:%M')}")
271
-
272
- errors = []
273
- if RESEND_API_KEY:
274
- err = _send_via_resend(recipients, subject, content)
275
- if not err:
276
- return f"✅ Sent to {', '.join(recipients)} (via Resend API)."
277
- errors.append(err)
278
- smtp_err = _send_via_smtp(recipients, subject, content)
279
- if not smtp_err:
280
- return f"✅ Sent to {', '.join(recipients)} (via SMTP)."
281
- errors.append(smtp_err)
282
-
283
- if not RESEND_API_KEY:
284
- return ("❌ SMTP is blocked on Hugging Face Spaces (outbound mail ports "
285
- "are closed). Fix: create a free key at resend.com and add it as "
286
- "a Space secret named **RESEND_API_KEY** (then it sends over "
287
- f"HTTPS). Detail: {smtp_err}")
288
- return "❌ Send failed. " + " | ".join(errors)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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}), 观望')
finetune_data.py CHANGED
@@ -1,105 +1,147 @@
1
  """
2
- finetune_data.py — capture (instruction, input, output) pairs from live app use,
3
- then export a clean JSONL ready for LoRA SFT (the 🎯 Well-Tuned badge).
4
-
5
- Every time the Signals "AI summary" runs, app.py calls `record()` with the
6
- English raw read (input) and the model's narrative (output). Pairs accumulate
7
- on the /data bucket and survive restarts; the Model tab has an "Export dataset"
8
- button that writes a timestamped JSONL and reports the count.
9
-
10
- The dataset teaches a small model ONE focused skill turn a Chan-theory raw
11
- read into a crisp long-hold trading summary which is exactly what the app's
12
- Translator sub-agent does. A 1.7B model fine-tuned on this beats a generic 4B
13
- at the task, and doubles as the Tiny Titan entry.
 
 
 
 
 
14
  """
15
  from __future__ import annotations
16
 
17
- import json
18
  import os
19
- import re
20
- import threading
21
- import datetime as dt
22
 
23
- import paths
24
-
25
- _PAIRS = os.path.join(paths.DATASET_DIR, "pairs.jsonl")
26
- _lock = threading.Lock()
27
-
28
- INSTRUCTION = ("You are an equity analyst. Based only on this factual read of a "
29
- "US stock's multi-timeframe Chan-theory verdict, write a short "
30
- "plain-English summary for a long-term holder: the situation "
31
- "today, whether to act or wait, and the key price levels. "
32
- "Max 90 words, no disclaimers.")
33
-
34
-
35
- def _clean(text: str) -> str:
36
- # strip stray think tags and any "AI narrative ..." UI prefix
37
- text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
38
- text = re.sub(r"^🤖\s*\*\*AI narrative[^\n]*\*\*\s*", "", text)
39
- text = text.replace("🤖 **AI narrative (Translator sub-agent · Qwen3-1.7B):**", "")
40
- return text.strip()
41
 
 
42
 
43
- def record(raw_read: str, narrative: str):
44
- """Append one training pair. Silently ignores junk / unloaded-model output."""
45
- narrative = _clean(narrative)
46
- if not raw_read or not narrative or len(narrative) < 40:
47
- return
48
- if narrative.startswith(("", "(", "Run the analysis")):
49
- return
50
- row = {"instruction": INSTRUCTION, "input": raw_read.strip(), "output": narrative}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  try:
52
- with _lock, open(_PAIRS, "a", encoding="utf-8") as f:
53
- f.write(json.dumps(row, ensure_ascii=False) + "\n")
54
- except OSError:
55
  pass
56
-
57
-
58
- def count() -> int:
59
- try:
60
- with open(_PAIRS, encoding="utf-8") as f:
61
- return sum(1 for _ in f)
62
- except OSError:
63
- return 0
64
-
65
-
66
- def export() -> str:
67
- """De-duplicate and write a timestamped JSONL. Returns the file path so the
68
- UI can offer it as a direct download."""
69
- n = count()
70
- if n == 0:
71
- return ""
72
- seen, rows = set(), []
73
- try:
74
- with open(_PAIRS, encoding="utf-8") as f:
75
- for line in f:
76
- line = line.strip()
77
- if not line:
78
- continue
79
- try:
80
- r = json.loads(line)
81
- except ValueError:
82
- continue
83
- key = (r.get("input", ""), r.get("output", ""))
84
- if key in seen:
85
- continue
86
- seen.add(key)
87
- rows.append(r)
88
- except OSError:
89
- return ""
90
- stamp = dt.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
91
- out = os.path.join(paths.DATASET_DIR, f"chan_sft_{stamp}.jsonl")
92
  try:
93
- with open(out, "w", encoding="utf-8") as f:
94
- for r in rows:
95
- f.write(json.dumps(r, ensure_ascii=False) + "\n")
96
- except OSError:
97
- return ""
98
- return out
99
-
100
-
101
- def status_line() -> str:
102
- n = count()
103
- if n == 0:
104
- return "_No fine-tuning pairs captured yet — run a few Signals AI summaries._"
105
- return f"📚 **{n}** training pair(s) captured on /data (target: 200-500 for a good LoRA)."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ import paths
29
 
30
+ CACHE_DIR = os.environ.get("CHAN_CACHE_DIR", paths.CACHE_DIR)
31
+
32
+ LEVELS = {
33
+ # level: (yfinance interval, period) Yahoo's max history per interval
34
+ "d": ("1d", "10y"),
35
+ "60m": ("60m", "730d"),
36
+ "30m": ("30m", "60d"),
37
+ "15m": ("15m", "60d"),
38
+ "5m": ("5m", "60d"),
39
+ "1m": ("1m", "7d"), # only 7 days available; short but usable for the
40
+ # finest nested-interval confirmation when present
41
+ }
42
+
43
+ _STALE_SECONDS = {"d": 12 * 3600, "60m": 2 * 3600, "30m": 2 * 3600,
44
+ "15m": 2 * 3600, "5m": 2 * 3600, "1m": 1800}
45
+
46
+
47
+ def _cache_path(ticker: str, level: str) -> str:
48
+ d = os.path.join(CACHE_DIR, ticker.upper().replace("/", "_"))
49
+ os.makedirs(d, exist_ok=True)
50
+ return os.path.join(d, f"{level}.parquet")
51
+
52
+
53
+ def _normalize(df: pd.DataFrame) -> pd.DataFrame:
54
+ """yfinance frame → engine schema (date/open/close/high/low/volume/amount)."""
55
+ if df is None or len(df) == 0:
56
+ return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
57
+ d = df.copy()
58
+ if isinstance(d.columns, pd.MultiIndex): # yf>=0.2 returns MultiIndex sometimes
59
+ d.columns = [c[0] if isinstance(c, tuple) else c for c in d.columns]
60
+ d = d.reset_index()
61
+ # index column may be 'Date' or 'Datetime'
62
+ for cand in ("Datetime", "Date", "index"):
63
+ if cand in d.columns:
64
+ d = d.rename(columns={cand: "date"})
65
+ break
66
+ d.columns = [str(c).lower() for c in d.columns]
67
+ keep = {"date", "open", "high", "low", "close", "volume"}
68
+ d = d[[c for c in d.columns if c in keep]]
69
+ d["date"] = pd.to_datetime(d["date"])
70
+ # strip timezone so comparisons with naive Timestamps in the engine work
71
  try:
72
+ d["date"] = d["date"].dt.tz_localize(None)
73
+ except (TypeError, AttributeError):
 
74
  pass
75
+ d = d.dropna(subset=["open", "high", "low", "close"])
76
+ d = d.sort_values("date").reset_index(drop=True)
77
+ d["amount"] = d["close"] * d.get("volume", 0)
78
+ return d[["date", "open", "close", "high", "low", "volume", "amount"]]
79
+
80
+
81
+ def load_level(ticker: str, level: str, force: bool = False) -> pd.DataFrame:
82
+ """Load one level for a ticker, using parquet cache when fresh."""
83
+ assert level in LEVELS, f"unknown level {level}"
84
+ path = _cache_path(ticker, level)
85
+ if not force and os.path.exists(path):
86
+ age = time.time() - os.path.getmtime(path)
87
+ if age < _STALE_SECONDS[level]:
88
+ try:
89
+ return pd.read_parquet(path)
90
+ except Exception:
91
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  try:
93
+ import yfinance as yf
94
+ interval, period = LEVELS[level]
95
+ raw = yf.Ticker(ticker).history(period=period, interval=interval,
96
+ auto_adjust=True, actions=False)
97
+ df = _normalize(raw)
98
+ if len(df):
99
+ df.to_parquet(path, index=False)
100
+ return df
101
+ except Exception:
102
+ traceback.print_exc()
103
+ # network failed → fall back to stale cache if any
104
+ if os.path.exists(path):
105
+ try:
106
+ return pd.read_parquet(path)
107
+ except Exception:
108
+ pass
109
+ return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
110
+
111
+
112
+ # Full nested-interval set (区间套): the more sub-levels confirm, the more
113
+ # precise the buy/sell point. We fetch the deepest Yahoo allows. 1m has only
114
+ # 7 days of history — included when present, skipped gracefully otherwise.
115
+ # Downloads are parallel + cached, so the extra levels cost little wall-time.
116
+ FULL_LEVELS = ("d", "60m", "30m", "15m", "5m", "1m")
117
+ FAST_LEVELS = FULL_LEVELS # default everywhere; alias kept for older callers
118
+
119
+
120
+ def load_levels(ticker: str, levels=FAST_LEVELS, force: bool = False) -> dict:
121
+ return {lvl: load_level(ticker, lvl, force=force) for lvl in levels}
122
+
123
+
124
+ def load_all_levels(ticker: str, force: bool = False) -> dict:
125
+ """Return {'d':…, '60m':…, '30m':…, '15m':…, '5m':…} (1m intentionally absent)."""
126
+ return {lvl: load_level(ticker, lvl, force=force) for lvl in LEVELS}
127
+
128
+
129
+ def prefetch(tickers, levels=FAST_LEVELS, force: bool = False, workers: int = 5,
130
+ budget_s: int = 45):
131
+ """Download all (ticker, level) pairs in parallel with a hard time budget.
132
+ Yahoo rate-limits datacenter IPs; without a budget one throttled request
133
+ could hang the whole Run-analysis click. Whatever isn't fetched in time is
134
+ skipped — the engine analyzes from daily/cached data and the next run
135
+ picks up the rest."""
136
+ from concurrent.futures import ThreadPoolExecutor, wait
137
+ jobs = [(t, lvl) for t in tickers for lvl in levels]
138
+ ex = ThreadPoolExecutor(max_workers=workers)
139
+ futs = [ex.submit(load_level, t, lvl, force) for t, lvl in jobs]
140
+ done, not_done = wait(futs, timeout=budget_s)
141
+ ex.shutdown(wait=False, cancel_futures=True)
142
+ return len(done), len(not_done)
143
+
144
+
145
+ def last_daily_date(ticker: str):
146
+ df = load_level(ticker, "d")
147
+ return None if df.empty else pd.Timestamp(df["date"].iloc[-1])
fonts.css ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ COLOR — Chan Compass · Spectrum 2
3
+ Two layers:
4
+ 1. PRIMITIVES — the raw Spectrum 2 scales (--s2-gray-100, --s2-blue-900…)
5
+ 2. SEMANTICS — purpose-named aliases the UI references (--text-body,
6
+ --surface-card, --accent…)
7
+ Always consume semantics in components; reach for a primitive only
8
+ when no semantic fits. Light theme is the default; .s2-dark flips the
9
+ semantic layer (primitives are not re-pointed here for simplicity).
10
+ ============================================================ */
11
+
12
+ :root {
13
+ /* ---- Gray (light) ---- */
14
+ --s2-gray-25: #ffffff;
15
+ --s2-gray-50: #f8f8f8;
16
+ --s2-gray-75: #f3f3f3;
17
+ --s2-gray-100: #e6e6e6;
18
+ --s2-gray-200: #d5d5d5;
19
+ --s2-gray-300: #b1b1b1;
20
+ --s2-gray-400: #909090;
21
+ --s2-gray-500: #6d6d6d;
22
+ --s2-gray-600: #464646;
23
+ --s2-gray-700: #292929;
24
+ --s2-gray-800: #1b1b1b;
25
+ --s2-gray-900: #000000;
26
+
27
+ /* ---- Blue (accent family) ---- */
28
+ --s2-blue-100: #e0f2ff;
29
+ --s2-blue-200: #cae8ff;
30
+ --s2-blue-300: #98cbfe;
31
+ --s2-blue-400: #6fb7fb;
32
+ --s2-blue-500: #4ba0ec;
33
+ --s2-blue-600: #2c84e0;
34
+ --s2-blue-700: #1473e6;
35
+ --s2-blue-800: #0a6fd6;
36
+ --s2-blue-900: #0265dc; /* accent default */
37
+ --s2-blue-1000: #0054b6; /* accent hover */
38
+ --s2-blue-1100: #00418f; /* accent down */
39
+
40
+ /* ---- Green (positive) ---- */
41
+ --s2-green-100: #ebf9ee;
42
+ --s2-green-400: #6cc788;
43
+ --s2-green-700: #15924a;
44
+ --s2-green-900: #007a39; /* positive default */
45
+ --s2-green-1000: #006d31;
46
+ --s2-green-1100: #00601f;
47
+
48
+ /* ---- Red (negative) ---- */
49
+ --s2-red-100: #ffefef;
50
+ --s2-red-400: #ff9b88;
51
+ --s2-red-700: #e34850;
52
+ --s2-red-900: #d7373f; /* negative default */
53
+ --s2-red-1000: #c0272d;
54
+ --s2-red-1100: #a01a1f;
55
+
56
+ /* ---- Orange (notice) ---- */
57
+ --s2-orange-100: #fdf0e1;
58
+ --s2-orange-400: #f8af5a;
59
+ --s2-orange-700: #cb6f10;
60
+ --s2-orange-900: #b25309; /* notice default */
61
+ --s2-orange-1000: #99490b;
62
+
63
+ /* ---- Indigo (informative accent / charts) ---- */
64
+ --s2-indigo-700: #5258e4;
65
+ --s2-indigo-900: #3d43d0;
66
+
67
+ /* ---- Purple (decorative / hero gradient stop) ---- */
68
+ --s2-purple-700: #8c42d6;
69
+ --s2-purple-900: #7326d3;
70
+
71
+ /* ============================================================
72
+ SEMANTIC ALIASES — what components actually reference
73
+ ============================================================ */
74
+
75
+ /* Surfaces & framing (Spectrum 2 background layering) */
76
+ --canvas: var(--s2-gray-50); /* app background */
77
+ --surface-card: var(--s2-gray-25); /* cards, panels */
78
+ --surface-elevated:var(--s2-gray-25); /* popovers, menus */
79
+ --surface-subtle: var(--s2-gray-75); /* table head, wells */
80
+ --surface-sunken: var(--s2-gray-75); /* inset fields */
81
+
82
+ /* Text */
83
+ --text-heading: var(--s2-gray-800);
84
+ --text-body: var(--s2-gray-700);
85
+ --text-secondary: var(--s2-gray-600);
86
+ --text-muted: var(--s2-gray-500);
87
+ --text-disabled: var(--s2-gray-400);
88
+ --text-on-accent: #ffffff;
89
+
90
+ /* Borders & dividers */
91
+ --border-hairline: var(--s2-gray-100);
92
+ --border-default: var(--s2-gray-200);
93
+ --border-strong: var(--s2-gray-300);
94
+ --border-field: var(--s2-gray-300);
95
+
96
+ /* Accent (Spectrum blue) */
97
+ --accent: var(--s2-blue-900);
98
+ --accent-hover: var(--s2-blue-1000);
99
+ --accent-down: var(--s2-blue-1100);
100
+ --accent-subtle: var(--s2-blue-100);
101
+ --accent-text: var(--s2-blue-1000);
102
+
103
+ /* Semantic status */
104
+ --positive: var(--s2-green-900);
105
+ --positive-hover: var(--s2-green-1000);
106
+ --positive-subtle: var(--s2-green-100);
107
+ --negative: var(--s2-red-900);
108
+ --negative-hover: var(--s2-red-1000);
109
+ --negative-subtle: var(--s2-red-100);
110
+ --notice: var(--s2-orange-900);
111
+ --notice-subtle: var(--s2-orange-100);
112
+ --informative: var(--s2-blue-900);
113
+ --informative-subtle: var(--s2-blue-100);
114
+
115
+ /* Focus ring */
116
+ --focus-ring: var(--s2-blue-900);
117
+
118
+ /* Market direction (this product's domain colors) */
119
+ --up: var(--s2-green-900); /* gains / BUY */
120
+ --down: var(--s2-red-900); /* losses / SELL */
121
+ --flat: var(--s2-gray-500); /* HOLD / WAIT */
122
+ }
123
+
124
+ /* ---- Dark theme: flip the semantic layer only ---- */
125
+ .s2-dark {
126
+ --canvas: #1d1d1d;
127
+ --surface-card: #262626;
128
+ --surface-elevated:#2c2c2c;
129
+ --surface-subtle: #222222;
130
+ --surface-sunken: #1a1a1a;
131
+
132
+ --text-heading: #f2f2f2;
133
+ --text-body: #e0e0e0;
134
+ --text-secondary: #b8b8b8;
135
+ --text-muted: #8f8f8f;
136
+ --text-disabled: #6a6a6a;
137
+
138
+ --border-hairline: #383838;
139
+ --border-default: #4a4a4a;
140
+ --border-strong: #5e5e5e;
141
+ --border-field: #5e5e5e;
142
+
143
+ --accent: #2680eb;
144
+ --accent-hover: #4ba0ec;
145
+ --accent-down: #6fb7fb;
146
+ --accent-subtle: #1a3a5c;
147
+ --accent-text: #6fb7fb;
148
+ }
llm_local.py CHANGED
@@ -1,307 +1,288 @@
1
  """
2
- llm_local.py — local sub-agent pool (llama.cpp runtime, no cloud APIs).
3
 
4
- Two independent model instances ("sub-agents"), each with its own lock, so
5
- features never block each other with "model busy":
 
6
 
7
- fast · Summary Sub-Agent (Chan-Tuned Qwen3-1.7B)
8
- Explain-in-English, sector-rotation narrative, news briefs.
9
- Small = quick CPU prefill, answers start streaming in seconds.
10
- deep · Analyst Qwen3-4B Q4_K_M by default (swappable in the Model tab)
11
- → the multi-step Auto Research agent's report writing.
12
-
13
- Both run through llama.cpp (llama-cpp-python) and are far below the 32B cap;
14
- the fast worker doubles as the "Tiny Titan" (≤4B) story. ~5 GB RAM total on a
15
- 32 GB Space. Earns "Off the Grid" + "Llama Champion".
16
  """
17
  from __future__ import annotations
18
 
19
  import os
 
 
 
 
 
 
20
  import re
21
- import threading
22
-
23
- import paths # sets HF_HOME + sys.path for /data persistence
24
- from huggingface_hub import hf_hub_download
25
-
26
- # name -> (HF repo, gguf filename)
27
- MODEL_ZOO = {
28
- "Chan-Tuned Qwen3-1.7B · my fine-tune": (
29
- "ranranrunforit/chan-compass-qwen3-1.7b-gguf", "qwen3-1.7b.Q8_0.gguf"),
30
- "Qwen3-1.7B · Tiny Titan (≤4B award class)": (
31
- "Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf"),
32
- "Qwen3-4B · default fast + smart, still ≤4B": (
33
- "Qwen/Qwen3-4B-GGUF", "Qwen3-4B-Q4_K_M.gguf"),
34
- "Qwen3-8B · best balance on 8 vCPU / 32 GB": (
35
- "Qwen/Qwen3-8B-GGUF", "Qwen3-8B-Q4_K_M.gguf"),
36
- "Qwen3-14B · max quality (still far under 32B cap)": (
37
- "Qwen/Qwen3-14B-GGUF", "Qwen3-14B-Q4_K_M.gguf"),
38
- }
39
- # Only the Summary sub-agent (Signals · Explain) uses the published
40
- # fine-tune; every other sub-agent stays on the stock models.
41
- FAST_MODEL = "Qwen3-1.7B · Tiny Titan (≤4B award class)"
42
- TRANSLATOR_MODEL = "Chan-Tuned Qwen3-1.7B · my fine-tune"
43
- DEFAULT_MODEL = "Qwen3-4B · default — fast + smart, still ≤4B"
44
-
45
- _THINK_RE = re.compile(r"<think>.*?</think>", re.S)
46
- _NCPU = max(2, (os.cpu_count() or 4))
47
-
48
- # One dedicated sub-agent per feature — independent locks, so Signals-Explain,
49
- # Rotation narrative, News briefs and Auto-Research never fight over a model.
50
- # Three tiny 1.7B instances share ONE GGUF file on disk (~2 GB RAM each) and
51
- # the 4B Analyst writes reports. Total 9 GB on a 32 GB Space.
52
- WORKER_LABEL = {
53
- "translator": "Summary sub-agent (Signals · Explain)",
54
- "narrator": "Narrator sub-agent (Sector Rotation)",
55
- "reporter": "Reporter sub-agent (News · Research support)",
56
- "analyst": "Analyst sub-agent (Auto Research)",
57
- }
58
-
59
-
60
- def _mk(model):
61
- return {"model": model, "llm": None, "lock": threading.Lock(),
62
- "load_lock": threading.Lock(), "stage": "idle", "detail": "", "ts": None}
63
-
64
-
65
- WORKERS = {
66
- "translator": _mk(TRANSLATOR_MODEL),
67
- "narrator": _mk(FAST_MODEL),
68
- "reporter": _mk(FAST_MODEL),
69
- "analyst": _mk(DEFAULT_MODEL),
70
  }
71
- # legacy aliases
72
- _ALIAS = {"fast": "translator", "deep": "analyst"}
73
-
74
 
75
- def _wk(worker: str) -> str:
76
- return _ALIAS.get(worker, worker)
77
-
78
- _install_lock = threading.Lock()
79
-
80
-
81
- def _set_stage(worker: str, stage: str, detail: str = ""):
82
- import datetime as _dt
83
- w = WORKERS[worker]
84
- w.update(stage=stage, detail=detail[:400],
85
- ts=_dt.datetime.utcnow().strftime("%H:%M:%S UTC"))
86
- try:
87
- import automation
88
- automation._log(f"[{WORKER_LABEL[worker]}] {stage}: {detail[:140]}")
89
- except Exception:
90
- pass
91
-
92
-
93
- # ─────────────────────── runtime install (once, persisted) ───────────────────────
94
- # Installed at RUNTIME, not at Space build time: the HF build container has
95
- # little RAM and gets OOM-killed compiling the C++ extension; the runtime
96
- # container has the real hardware. Prebuilt CPU wheel first, capped-parallelism
97
- # source build as fallback. Persisted to /data/pylibs.
98
- _WHEEL_INDEX = "https://abetlen.github.io/llama-cpp-python/whl/cpu"
99
- _LLAMA_REQ = "llama-cpp-python>=0.3.8" # >=0.3.8 → Qwen3 architecture support
100
-
101
-
102
- def _ensure_llama_cpp(worker: str) -> str:
103
- try:
104
- import llama_cpp # noqa: F401
105
- return ""
106
- except ImportError:
107
- pass
108
- import subprocess
109
- import sys
110
- with _install_lock:
111
- try: # another thread may have finished it while we waited
112
- import llama_cpp # noqa: F401
113
- return ""
114
- except ImportError:
115
- pass
116
- env = dict(os.environ)
117
- env["CMAKE_BUILD_PARALLEL_LEVEL"] = "4"
118
- _set_stage(worker, "installing llama.cpp runtime",
119
- "trying official prebuilt CPU wheel (≈1 min)…")
120
- if paths.PERSISTENT:
121
- base = [sys.executable, "-m", "pip", "install", "--prefer-binary",
122
- "--target", paths.PYLIBS_DIR]
123
- else:
124
- base = [sys.executable, "-m", "pip", "install", "--user", "--prefer-binary"]
125
- r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX,
126
- "--only-binary", "llama-cpp-python", _LLAMA_REQ],
127
- capture_output=True, text=True, env=env, timeout=600)
128
- if r.returncode != 0:
129
- _set_stage(worker, "installing llama.cpp runtime",
130
- "no prebuilt wheel matched — compiling from source "
131
- "(one-time ~10-15 min; other tabs keep working)…")
132
- r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX, _LLAMA_REQ],
133
- capture_output=True, text=True, env=env, timeout=2400)
134
- if r.returncode != 0:
135
- err = (r.stderr or r.stdout or "")[-800:]
136
- _set_stage(worker, "install FAILED", err)
137
- return "Could not install llama-cpp-python at runtime:\n" + err
138
- import importlib
139
- import site
140
- cands = [paths.PYLIBS_DIR] if paths.PERSISTENT else []
141
- usp = site.getusersitepackages()
142
- cands += usp if isinstance(usp, list) else [usp]
143
- for p in cands:
144
- if p and p not in sys.path:
145
- sys.path.append(p)
146
- importlib.invalidate_caches()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  try:
148
- import llama_cpp # noqa: F401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  return ""
150
- except Exception as e:
151
- _set_stage(worker, "install FAILED", f"installed but import failed: {e}")
152
- return f"Installed but import failed: {e}"
153
-
154
-
155
- # ─────────────────────── loading ───────────────────────
156
- def load_model(name: str, worker: str = "analyst") -> str:
157
- worker = _wk(worker)
158
- """Load a GGUF into a worker slot. Non-blocking: if that worker is already
159
- installing/loading, returns its live stage instead of hanging the click."""
160
- w = WORKERS[worker]
161
- if not w["load_lock"].acquire(timeout=2):
162
- return (f"⏳ {WORKER_LABEL[worker]} is busy — current stage: "
163
- f"**{w['stage']}** ({w['detail'] or '…'}). Press “↻ Refresh status”.")
 
 
 
 
 
 
 
164
  try:
165
- if w["llm"] is not None and w["model"] == name:
166
- return f"Already loaded on {WORKER_LABEL[worker]}: {name}"
167
- err = _ensure_llama_cpp(worker)
168
- if err:
169
- return err
170
- try:
171
- from llama_cpp import Llama
172
- except Exception as e:
173
- _set_stage(worker, "import FAILED", str(e))
174
- return f"llama-cpp-python is not available: {e}"
175
- repo, fname = MODEL_ZOO[name]
176
- try:
177
- _set_stage(worker, "downloading GGUF",
178
- f"{repo}/{fname} (cached on /data after first time)")
179
- path = hf_hub_download(repo_id=repo, filename=fname)
180
- except Exception as e:
181
- _set_stage(worker, "download FAILED", str(e))
182
- return f"Could not download {repo}/{fname}: {e}"
183
- try:
184
- _set_stage(worker, "loading model into RAM", name)
185
- w["llm"] = None
186
- small = worker != "analyst"
187
- w["llm"] = Llama(
188
- model_path=path,
189
- n_ctx=4096 if small else 6144,
190
- n_threads=(4 if small else _NCPU), # leave headroom for parallel agents
191
- n_threads_batch=(6 if small else _NCPU),
192
- n_batch=512,
193
- verbose=False,
194
- )
195
- w["model"] = name
196
- _set_stage(worker, "ready", name)
197
- return f"✅ {WORKER_LABEL[worker]} ready: {name}"
198
- except Exception as e:
199
- w["llm"] = None
200
- _set_stage(worker, "load FAILED", str(e))
201
- return f"Failed to load model: {e}"
202
- finally:
203
- w["load_lock"].release()
204
-
205
-
206
- def auto_load_all():
207
- """Startup: tiny agents first (one small GGUF download serves all three),
208
- then the Analyst. Runs in a background thread."""
209
- for key in ("translator", "narrator", "reporter", "analyst"):
210
- load_model(WORKERS[key]["model"], worker=key)
211
-
212
-
213
- # ─────────────────────── status ───────────────────────
214
- def is_loaded(worker: str = None) -> bool:
215
- if worker:
216
- return WORKERS[_wk(worker)]["llm"] is not None
217
- return any(w["llm"] is not None for w in WORKERS.values())
218
-
219
-
220
- def status() -> str:
221
- lines = []
222
- for key in ("translator", "narrator", "reporter", "analyst"):
223
- w = WORKERS[key]
224
- label = WORKER_LABEL[key]
225
- if w["llm"] is not None:
226
- lines.append(f"✅ **{label}** — {w['model']} · llama.cpp, local")
227
- elif w["stage"] == "idle":
228
- lines.append(f"⚪ **{label}** — not loaded yet (auto-loads at startup)")
229
  else:
230
- lines.append(f"⏳ **{label}** — {w['stage']} ({w['ts']}): {w['detail'] or '…'}")
231
- lines.append("\n_Each sub-agent has its own lock — Explain / narrative / "
232
- "research run in parallel without “model busy”._")
233
- return "\n\n".join(lines)
234
-
235
-
236
- # ─────────────────────── inference ───────────────────────
237
- DEFAULT_SYSTEM = ("You are a sub-agent of Chan Compass, a US-equity dashboard. "
238
- "Answer in clear, concise English.")
239
- MAX_PROMPT_CHARS = 3200
240
-
241
-
242
- def _messages(user: str, system: str):
243
- return [{"role": "system", "content": system + " /no_think"},
244
- {"role": "user", "content": user[:MAX_PROMPT_CHARS]}]
245
-
246
-
247
- def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
248
- system: str = DEFAULT_SYSTEM, worker: str = "translator") -> str:
249
- """Blocking chat on one sub-agent (used by pipeline/agent code)."""
250
- w = WORKERS[_wk(worker)]; worker = _wk(worker)
251
- if w["llm"] is None:
252
  return ""
253
- if not w["lock"].acquire(timeout=180):
254
- return f"({WORKER_LABEL[worker]} busy try again in a moment)"
255
- try:
256
- out = w["llm"].create_chat_completion(
257
- messages=_messages(user, system),
258
- max_tokens=max_tokens, temperature=temperature)
259
- txt = out["choices"][0]["message"]["content"] or ""
260
- return _THINK_RE.sub("", txt).strip()
261
  except Exception as e:
262
- return f"(model error: {e})"
263
  finally:
264
- w["lock"].release()
265
-
266
-
267
- def chat_stream(user: str, max_tokens: int = 500, temperature: float = 0.3,
268
- system: str = DEFAULT_SYSTEM, worker: str = "translator"):
269
- """Streaming chat on one sub-agent yields cumulative text immediately."""
270
- w = WORKERS[_wk(worker)]; worker = _wk(worker)
271
- if w["llm"] is None:
272
- yield (f"⏳ {WORKER_LABEL[worker]} isn't ready yet "
273
- f"stage: {w['stage']}. Check the **Model** tab.")
274
- return
275
- if not w["lock"].acquire(timeout=5):
276
- yield f" {WORKER_LABEL[worker]} is finishing another answer try again in a few seconds."
277
- return
278
- try:
279
- acc = ""
280
- for chunk in w["llm"].create_chat_completion(
281
- messages=_messages(user, system),
282
- max_tokens=max_tokens, temperature=temperature, stream=True):
283
- delta = chunk["choices"][0]["delta"].get("content") or ""
284
- if not delta:
285
- continue
286
- acc += delta
287
- yield _THINK_RE.sub("", acc).replace("<think>", "").strip()
288
- if not acc.strip():
289
- yield "(model returned no text try again)"
290
- except Exception as e:
291
- yield f"(model error: {e})"
292
- finally:
293
- w["lock"].release()
294
-
295
-
296
- def quick_test() -> str:
297
- """Sanity check both sub-agents."""
298
- import time
299
- outs = []
300
- for key in ("translator", "narrator", "reporter", "analyst"):
301
- if WORKERS[key]["llm"] is None:
302
- outs.append(f"{WORKER_LABEL[key]}: not loaded ({WORKERS[key]['stage']})")
303
- continue
304
- t0 = time.time()
305
- out = chat("Reply with exactly: OK", max_tokens=6, temperature=0.0, worker=key)
306
- outs.append(f"{WORKER_LABEL[key]}: **{out or '(no output)'}** · {time.time()-t0:.1f}s")
307
- return "\n\n".join(outs)
 
1
  """
2
+ emailer.py — send a tab's AI result to any address (English UI + content).
3
 
4
+ Adapted from the user's send_predict_email.py: same multi-domain SMTP
5
+ auto-detection, plain+HTML multipart, but English subjects/labels and a generic
6
+ `send_result()` the Gradio tabs call.
7
 
8
+ Sender credentials live here (a Gmail App Password). To change the sender, edit
9
+ EMAIL_SENDER / EMAIL_PASSWORD. You can also override them with the environment
10
+ variables CHAN_EMAIL_SENDER / CHAN_EMAIL_PASSWORD on the Space (recommended:
11
+ store the App Password as a Space *secret* rather than in code).
 
 
 
 
 
12
  """
13
  from __future__ import annotations
14
 
15
  import os
16
+ import json
17
+ import html
18
+ import logging
19
+ import smtplib
20
+ import urllib.request
21
+ import urllib.error
22
  import re
23
+ from datetime import datetime
24
+ from email.mime.text import MIMEText
25
+ from email.mime.multipart import MIMEMultipart
26
+ from email.header import Header
27
+ from email.utils import formataddr
28
+
29
+ logger = logging.getLogger("chan_emailer")
30
+
31
+ # ── Transport 1 (preferred on HF): Resend HTTPS API on port 443 ──
32
+ # HF Spaces block outbound SMTP ports (465/587) → SMTP fails with
33
+ # "Network is unreachable". The Resend REST API uses plain HTTPS, which Spaces
34
+ # allow. Set a Space secret RESEND_API_KEY to enable it. Free tier ≈ 100/day.
35
+ # Until you verify your own domain, Resend only lets you send FROM
36
+ # "onboarding@resend.dev" that's the default sender below.
37
+ RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
38
+ RESEND_FROM = os.environ.get("RESEND_FROM", "Chan Compass <onboarding@resend.dev>")
39
+
40
+ # ── Transport 2 (fallback, works off-HF): classic SMTP ──
41
+ EMAIL_SENDER = os.environ.get("CHAN_EMAIL_SENDER", "")
42
+ EMAIL_PASSWORD = os.environ.get("CHAN_EMAIL_PASSWORD", "")
43
+
44
+ SMTP_CONFIGS = {
45
+ "gmail.com": {"server": "smtp.gmail.com", "port": 465, "ssl": True},
46
+ "qq.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
47
+ "163.com": {"server": "smtp.163.com", "port": 465, "ssl": True},
48
+ "126.com": {"server": "smtp.126.com", "port": 465, "ssl": True},
49
+ "outlook.com": {"server": "smtp.office365.com", "port": 587, "ssl": False},
50
+ "hotmail.com": {"server": "smtp.office365.com", "port": 587, "ssl": False},
51
+ "foxmail.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
52
+ "sina.com": {"server": "smtp.sina.com", "port": 465, "ssl": True},
53
+ "aliyun.com": {"server": "smtp.aliyun.com", "port": 465, "ssl": True},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  }
 
 
 
55
 
56
+ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
57
+
58
+
59
+ def _sender_address(sender: str) -> str:
60
+ return formataddr((str(Header("Chan Compass", "utf-8")), sender))
61
+
62
+
63
+ def _md_to_plain(text: str) -> str:
64
+ """Markdown -> readable plain text (for the text/plain MIME part and the
65
+ Resend `text` field). Strips #/**/| noise so text-only clients look clean."""
66
+ lines, out = text.split("\n"), []
67
+ i, n = 0, len(lines)
68
+ while i < n:
69
+ line = lines[i]
70
+ if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", lines[i + 1]):
71
+ header = [c.strip() for c in line.strip().strip("|").split("|")]
72
+ i += 2
73
+ while i < n and "|" in lines[i]:
74
+ cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
75
+ if len(header) == 2 and len(cells) == 2:
76
+ out.append(f" {cells[0]}: {cells[1]}")
77
+ else:
78
+ out.append(" " + " | ".join(cells))
79
+ i += 1
80
+ out.append("")
81
+ continue
82
+ m = re.match(r"^(#{1,4})\s+(.*)$", line)
83
+ if m:
84
+ title = m.group(2).strip()
85
+ out.append("")
86
+ out.append(title.upper() if len(m.group(1)) <= 2 else title)
87
+ out.append("-" * min(len(title), 60))
88
+ i += 1
89
+ continue
90
+ if line.strip() in ("---", "***", "___"):
91
+ out.append("-" * 40)
92
+ i += 1
93
+ continue
94
+ s = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
95
+ s = re.sub(r"`(.+?)`", r"\1", s)
96
+ s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", r"\1 (\2)", s)
97
+ s = re.sub(r"^_(.+)_$", r"\1", s.strip())
98
+ out.append(s)
99
+ i += 1
100
+ txt = "\n".join(out)
101
+ return re.sub(r"\n{3,}", "\n\n", txt).strip()
102
+
103
+
104
+ def _inline_md(s: str) -> str:
105
+ s = html.escape(s)
106
+ s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
107
+ s = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", s)
108
+ s = re.sub(r"`(.+?)`", r"<code>\1</code>", s)
109
+ s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)",
110
+ r'<a href="\2">\1</a>', s)
111
+ return s
112
+
113
+
114
+ def _md_to_html(text: str) -> str:
115
+ """Minimal but correct Markdown → HTML (headings, tables, lists, bold,
116
+ code, links). The reports are Markdown, so emailing them as preformatted
117
+ text showed raw `#`/`|` symbols — this renders them properly."""
118
+ lines = text.split("\n")
119
+ out, i, n = [], 0, len(lines)
120
+ while i < n:
121
+ line = lines[i]
122
+ # table block: a header row of pipes followed by a |---| separator
123
+ if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", lines[i + 1]):
124
+ header = [c.strip() for c in line.strip().strip("|").split("|")]
125
+ i += 2
126
+ rows = []
127
+ while i < n and "|" in lines[i]:
128
+ rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
129
+ i += 1
130
+ th = "".join(f"<th style='text-align:left;padding:6px 10px;"
131
+ f"border-bottom:2px solid #ddd'>{_inline_md(c)}</th>" for c in header)
132
+ trs = ""
133
+ for r in rows:
134
+ tds = "".join(f"<td style='padding:6px 10px;border-bottom:1px solid #eee'>"
135
+ f"{_inline_md(c)}</td>" for c in r)
136
+ trs += f"<tr>{tds}</tr>"
137
+ out.append(f"<table style='border-collapse:collapse;margin:10px 0;"
138
+ f"font-size:13px'><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table>")
139
+ continue
140
+ m = re.match(r"^(#{1,4})\s+(.*)$", line)
141
+ if m:
142
+ lvl = len(m.group(1))
143
+ size = {1: 20, 2: 16, 3: 14, 4: 13}[lvl]
144
+ out.append(f"<h{lvl} style='font-size:{size}px;margin:14px 0 6px;"
145
+ f"color:#0265DC'>{_inline_md(m.group(2))}</h{lvl}>")
146
+ i += 1
147
+ continue
148
+ if re.match(r"^\s*[-*]\s+", line):
149
+ items = []
150
+ while i < n and re.match(r"^\s*[-*]\s+", lines[i]):
151
+ item_text = re.sub(r"^\s*[-*]\s+", "", lines[i])
152
+ items.append(f"<li>{_inline_md(item_text)}</li>")
153
+ i += 1
154
+ out.append(f"<ul style='margin:6px 0 6px 18px'>{''.join(items)}</ul>")
155
+ continue
156
+ if line.strip() in ("---", "***", "___"):
157
+ out.append("<hr style='border:none;border-top:1px solid #e3e6ea;margin:12px 0'>")
158
+ i += 1
159
+ continue
160
+ if line.strip() == "":
161
+ out.append("<div style='height:6px'></div>")
162
+ i += 1
163
+ continue
164
+ out.append(f"<p style='margin:4px 0'>{_inline_md(line)}</p>")
165
+ i += 1
166
+ body = "".join(out)
167
+ return (
168
+ '<html><body style="margin:0;padding:18px;background:#f6f7f9;">'
169
+ '<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;'
170
+ 'font-size:14px;line-height:1.55;color:#1a1a1a;background:#ffffff;'
171
+ 'padding:22px 26px;border-radius:12px;border:1px solid #e3e6ea;'
172
+ 'max-width:780px;margin:0 auto;">'
173
+ f'{body}'
174
+ '<p style="font-size:11px;color:#8a8a8a;margin:16px 0 0;border-top:'
175
+ '1px solid #eee;padding-top:10px;">Sent by Chan Compass · educational '
176
+ 'tool, not investment advice.</p>'
177
+ '</div></body></html>'
178
+ )
179
+
180
+
181
+ def _parse_recipients(raw: str) -> list:
182
+ parts = re.split(r"[,;\s]+", (raw or "").strip())
183
+ return [p for p in parts if _EMAIL_RE.match(p)]
184
+
185
+
186
+ def _close(server):
187
+ if server is not None:
188
  try:
189
+ server.quit()
190
+ except Exception:
191
+ try:
192
+ server.close()
193
+ except Exception:
194
+ pass
195
+
196
+
197
+ def _send_via_resend(recipients: list, subject: str, content: str) -> str:
198
+ """HTTPS POST to Resend — works on HF (port 443). Returns '' on success."""
199
+ payload = json.dumps({
200
+ "from": RESEND_FROM,
201
+ "to": recipients,
202
+ "subject": subject,
203
+ "text": _md_to_plain(content),
204
+ "html": _md_to_html(content),
205
+ }).encode("utf-8")
206
+ req = urllib.request.Request(
207
+ "https://api.resend.com/emails", data=payload, method="POST",
208
+ headers={"Authorization": f"Bearer {RESEND_API_KEY}",
209
+ "Content-Type": "application/json",
210
+ # Resend/Cloudflare reject requests with no User-Agent
211
+ # (403, error code 1010) before they reach the API.
212
+ "User-Agent": "chan-compass/1.0 (+https://huggingface.co/spaces)"})
213
+ try:
214
+ with urllib.request.urlopen(req, timeout=30) as resp:
215
+ body = resp.read().decode("utf-8", "ignore")
216
+ if '"id"' in body:
217
  return ""
218
+ return f"Resend API responded without an id: {body[:200]}"
219
+ except urllib.error.HTTPError as e:
220
+ detail = e.read().decode("utf-8", "ignore")[:200]
221
+ return f"Resend HTTP {e.code}: {detail}"
222
+ except Exception as e:
223
+ return f"Resend request failed: {e}"
224
+
225
+
226
+ def _send_via_smtp(recipients: list, subject: str, content: str) -> str:
227
+ """Classic SMTP works locally / off-HF. Returns '' on success."""
228
+ if not EMAIL_SENDER or not EMAIL_PASSWORD:
229
+ return "SMTP sender not configured."
230
+ msg = MIMEMultipart("alternative")
231
+ msg["Subject"] = Header(subject, "utf-8")
232
+ msg["From"] = _sender_address(EMAIL_SENDER)
233
+ msg["To"] = ", ".join(recipients)
234
+ msg.attach(MIMEText(_md_to_plain(content), "plain", "utf-8"))
235
+ msg.attach(MIMEText(_md_to_html(content), "html", "utf-8"))
236
+ domain = EMAIL_SENDER.split("@")[-1].lower()
237
+ sc = SMTP_CONFIGS.get(domain, {"server": f"smtp.{domain}", "port": 465, "ssl": True})
238
+ server = None
239
  try:
240
+ if sc["ssl"]:
241
+ server = smtplib.SMTP_SSL(sc["server"], sc["port"], timeout=20)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  else:
243
+ server = smtplib.SMTP(sc["server"], sc["port"], timeout=20)
244
+ server.starttls()
245
+ server.login(EMAIL_SENDER, EMAIL_PASSWORD)
246
+ server.send_message(msg)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  return ""
248
+ except smtplib.SMTPAuthenticationError:
249
+ return "SMTP authentication failed (check sender / App Password)."
250
+ except OSError as e:
251
+ return f"SMTP network error: {e}"
 
 
 
 
252
  except Exception as e:
253
+ return f"SMTP failed: {e}"
254
  finally:
255
+ _close(server)
256
+
257
+
258
+ def send_result(content: str, recipients_raw: str, subject_tag: str) -> str:
259
+ """Send `content` to the address(es). Prefers the Resend HTTPS API (works on
260
+ HF Spaces); falls back to SMTP. English status string for the UI."""
261
+ if not content or not content.strip():
262
+ return "⚠️ Nothing to send yet — generate a result first."
263
+ if content.strip().startswith(("⏳", "🤖 _", "Run ", "Enter ", "Select ")):
264
+ return "⚠️ Wait for the AI result to finish, then send."
265
+ recipients = _parse_recipients(recipients_raw)
266
+ if not recipients:
267
+ return "⚠️ Enter a valid email address (e.g. name@example.com)."
268
+
269
+ subject = (f"Chan Compass · {subject_tag} · "
270
+ f"{datetime.now().strftime('%Y-%m-%d %H:%M')}")
271
+
272
+ errors = []
273
+ if RESEND_API_KEY:
274
+ err = _send_via_resend(recipients, subject, content)
275
+ if not err:
276
+ return f"✅ Sent to {', '.join(recipients)} (via Resend API)."
277
+ errors.append(err)
278
+ smtp_err = _send_via_smtp(recipients, subject, content)
279
+ if not smtp_err:
280
+ return f" Sent to {', '.join(recipients)} (via SMTP)."
281
+ errors.append(smtp_err)
282
+
283
+ if not RESEND_API_KEY:
284
+ return ("❌ SMTP is blocked on Hugging Face Spaces (outbound mail ports "
285
+ "are closed). Fix: create a free key at resend.com and add it as "
286
+ "a Space secret named **RESEND_API_KEY** (then it sends over "
287
+ f"HTTPS). Detail: {smtp_err}")
288
+ return " Send failed. " + " | ".join(errors)
 
 
 
 
 
 
 
 
 
 
news_watch.py CHANGED
@@ -1,189 +1,105 @@
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
  import paths
15
 
16
- HOLDINGS_FILE = os.path.join(paths.OUTPUT_DIR, "holdings.txt")
 
 
 
 
 
 
 
 
17
 
 
 
 
 
 
 
18
 
19
- # ── holdings persistence ─────────────────────────────────────────────────
20
- def load_holdings() -> list:
 
 
 
 
 
 
 
21
  try:
22
- with open(HOLDINGS_FILE, encoding="utf-8") as f:
23
- return [x.strip().upper() for x in f if x.strip()]
24
- except FileNotFoundError:
25
- return []
26
-
27
-
28
- def save_holdings(tickers: list):
29
- seen, out = set(), []
30
- for t in tickers:
31
- t = t.strip().upper()
32
- if t and t not in seen:
33
- seen.add(t)
34
- out.append(t)
35
- with open(HOLDINGS_FILE, "w", encoding="utf-8") as f:
36
- f.write("\n".join(out))
37
- return out
38
 
39
 
40
- # ── news fetch ───────────────────────────────────────────────────────────
41
- def _today_utc() -> dt.date:
42
- return dt.datetime.now(dt.timezone.utc).date()
 
 
 
43
 
44
 
45
- def fetch_today_news(ticker: str) -> list:
46
- """Return today's news items: [{'title','publisher','time','link'}…]."""
 
 
 
 
 
47
  try:
48
- import yfinance as yf
49
- items = yf.Ticker(ticker).news or []
50
- except Exception:
51
- traceback.print_exc()
52
- return []
53
- today = _today_utc()
54
- out = []
55
- for it in items:
56
- # yfinance has two schemas: legacy flat dict, or {'content': {...}}
57
- c = it.get("content", it)
58
- title = c.get("title") or ""
59
- ts = it.get("providerPublishTime")
60
- when = None
61
- if ts:
62
- when = dt.datetime.fromtimestamp(ts, dt.timezone.utc)
63
- else:
64
- pub = c.get("pubDate") or c.get("displayTime")
65
- if pub:
66
  try:
67
- when = dt.datetime.fromisoformat(str(pub).replace("Z", "+00:00"))
68
  except ValueError:
69
- when = None
70
- if when is None or when.date() != today:
71
- continue
72
- pubr = c.get("publisher") or (c.get("provider") or {}).get("displayName") or ""
73
- link = c.get("link") or (c.get("canonicalUrl") or {}).get("url") or ""
74
- if title:
75
- out.append({"title": title, "publisher": pubr,
76
- "time": when.strftime("%H:%M UTC"), "link": link})
77
- return out
78
-
79
-
80
- def _llm_brief(ticker: str, items: list) -> str:
81
- heads = "\n".join(f"- [{x['time']}] {x['title']} ({x['publisher']})" for x in items)
82
  try:
83
- import llm_local
84
- if not llm_local.is_loaded():
85
- return ""
86
- prompt = (
87
- f"You are an equity news analyst. Today's headlines for {ticker} "
88
- f"(a stock the user currently HOLDS):\n{heads}\n\n"
89
- "In ENGLISH ONLY, write:\n"
90
- "1) **Per-headline:** one short line per headline above — what it says "
91
- "and why it matters (or 'noise') for the holding;\n"
92
- "2) **Net read:** POSITIVE / NEGATIVE / NEUTRAL with one sentence why;\n"
93
- "3) **Action:** one concrete suggestion. ≤180 words, no disclaimers."
94
- )
95
- return llm_local.chat(prompt, max_tokens=420, worker="reporter")
96
- except Exception:
97
  return ""
 
98
 
99
 
100
- def check_holdings_news_stream(tickers=None):
101
- """Generator for the UI: emits cumulative markdown as it goes — each ticker,
102
- each headline, and each AI brief appears the moment it's ready, so the user
103
- never stares at a frozen screen. AI briefs run on the Reporter sub-agent."""
104
- import llm_local
105
- tickers = tickers if tickers is not None else load_holdings()
106
- if not tickers:
107
- yield ("**No holdings configured.** Add tickers above (e.g. `AAPL, NVDA`) "
108
- "and save — they'll be checked for news every day.")
109
- return
110
- stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
111
- out = [f"_Checking {len(tickers)} holding(s) · {stamp}_"]
112
- quiet = []
113
-
114
- def render():
115
- body = "\n".join(out)
116
- if quiet:
117
- body += f"\n\n**Quiet today (no news):** {', '.join(quiet)}"
118
- return body
119
-
120
- for t in tickers:
121
- out.append(f"\n### 📰 {t} …searching today's news")
122
- yield render()
123
- items = fetch_today_news(t)
124
- if not items:
125
- out.pop() # drop the "searching" line
126
- quiet.append(t)
127
- yield render()
128
- continue
129
- out[-1] = f"\n### 📰 {t} — {len(items)} item(s) today"
130
- yield render()
131
- # print each headline the moment we have it
132
- for x in items[:6]:
133
- link = f" · [link]({x['link']})" if x["link"] else ""
134
- out.append(f"- **{x['time']}** {x['title']} — *{x['publisher']}*{link}")
135
- yield render()
136
- # then stream the AI brief for this ticker (Reporter sub-agent)
137
- if llm_local.is_loaded("reporter") or llm_local.is_loaded("translator"):
138
- wk = "reporter" if llm_local.is_loaded("reporter") else "translator"
139
- heads = "\n".join(f"- [{x['time']}] {x['title']} ({x['publisher']})"
140
- for x in items[:6])
141
- prompt = (
142
- f"You are an equity news analyst. Today's headlines for {ticker_safe(t)} "
143
- f"(a stock the user HOLDS):\n{heads}\n\nIn ENGLISH ONLY:\n"
144
- f"1) **Per-headline:** one short line each — what it says and why it "
145
- f"matters (or 'noise');\n2) **Net read:** POSITIVE / NEGATIVE / NEUTRAL "
146
- f"+ one sentence;\n3) **Action:** one concrete suggestion. ≤180 words.")
147
- out.append("\n> 🤖 **Reporter sub-agent brief:** _thinking…_")
148
- base = len(out) - 1
149
- for acc in llm_local.chat_stream(prompt, max_tokens=420, worker=wk):
150
- out[base] = "> 🤖 **Reporter sub-agent brief:**\n>\n> " + \
151
- acc.replace("\n", "\n> ")
152
- yield render()
153
- else:
154
- out.append("\n> _Model still loading — headlines shown; brief will work shortly._")
155
- yield render()
156
- out.append(f"\n_Done · {stamp}_")
157
- yield render()
158
-
159
-
160
- def ticker_safe(t):
161
- return str(t).upper()
162
-
163
-
164
- def check_holdings_news(tickers=None) -> str:
165
- """Markdown report: AI brief per holding with today-news; quiet list otherwise."""
166
- tickers = tickers if tickers is not None else load_holdings()
167
- if not tickers:
168
- return ("**No holdings configured.** Add tickers above (e.g. `AAPL, NVDA`) "
169
- "and save — they'll be checked for news every day.")
170
- blocks, quiet = [], []
171
- for t in tickers:
172
- items = fetch_today_news(t)
173
- if not items:
174
- quiet.append(t)
175
- continue
176
- lines = [f"### 📰 {t} — {len(items)} item(s) today"]
177
- for x in items[:6]:
178
- link = f" · [link]({x['link']})" if x["link"] else ""
179
- lines.append(f"- **{x['time']}** {x['title']} — *{x['publisher']}*{link}")
180
- brief = _llm_brief(t, items)
181
- if brief:
182
- lines.append(f"\n> 🤖 **AI brief:** {brief}")
183
- else:
184
- lines.append("\n> _Load a model in the Model tab for an AI brief._")
185
- blocks.append("\n".join(lines))
186
- if quiet:
187
- blocks.append(f"**Quiet today (no news, ignored):** {', '.join(quiet)}")
188
- stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
189
- return f"_Checked {stamp}_\n\n" + ("\n\n---\n\n".join(blocks) if blocks else "No output.")
 
1
  """
2
+ finetune_data.py — capture (instruction, input, output) pairs from live app use,
3
+ then export a clean JSONL ready for LoRA SFT (the 🎯 Well-Tuned badge).
4
+
5
+ Every time the Signals "AI summary" runs, app.py calls `record()` with the
6
+ English raw read (input) and the model's narrative (output). Pairs accumulate
7
+ on the /data bucket and survive restarts; the Model tab has an "Export dataset"
8
+ button that writes a timestamped JSONL and reports the count.
9
+
10
+ The dataset teaches a small model ONE focused skill — turn a Chan-theory raw
11
+ read into a crisp long-hold trading summary — which is exactly what the app's
12
+ Translator sub-agent does. A 1.7B model fine-tuned on this beats a generic 4B
13
+ at the task, and doubles as the Tiny Titan entry.
14
  """
15
  from __future__ import annotations
16
 
17
+ import json
18
  import os
19
+ import re
20
+ import threading
21
+ import datetime as dt
22
 
23
  import paths
24
 
25
+ _PAIRS = os.path.join(paths.DATASET_DIR, "pairs.jsonl")
26
+ _lock = threading.Lock()
27
+
28
+ INSTRUCTION = ("You are an equity analyst. Based only on this factual read of a "
29
+ "US stock's multi-timeframe Chan-theory verdict, write a short "
30
+ "plain-English summary for a long-term holder: the situation "
31
+ "today, whether to act or wait, and the key price levels. "
32
+ "Max 90 words, no disclaimers.")
33
+
34
 
35
+ def _clean(text: str) -> str:
36
+ # strip stray think tags and any "AI narrative ..." UI prefix
37
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
38
+ text = re.sub(r"^🤖\s*\*\*AI narrative[^\n]*\*\*\s*", "", text)
39
+ text = text.replace("🤖 **AI narrative (Translator sub-agent · Qwen3-1.7B):**", "")
40
+ return text.strip()
41
 
42
+
43
+ def record(raw_read: str, narrative: str):
44
+ """Append one training pair. Silently ignores junk / unloaded-model output."""
45
+ narrative = _clean(narrative)
46
+ if not raw_read or not narrative or len(narrative) < 40:
47
+ return
48
+ if narrative.startswith(("⏳", "(", "Run the analysis")):
49
+ return
50
+ row = {"instruction": INSTRUCTION, "input": raw_read.strip(), "output": narrative}
51
  try:
52
+ with _lock, open(_PAIRS, "a", encoding="utf-8") as f:
53
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
54
+ except OSError:
55
+ pass
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
 
58
+ def count() -> int:
59
+ try:
60
+ with open(_PAIRS, encoding="utf-8") as f:
61
+ return sum(1 for _ in f)
62
+ except OSError:
63
+ return 0
64
 
65
 
66
+ def export() -> str:
67
+ """De-duplicate and write a timestamped JSONL. Returns the file path so the
68
+ UI can offer it as a direct download."""
69
+ n = count()
70
+ if n == 0:
71
+ return ""
72
+ seen, rows = set(), []
73
  try:
74
+ with open(_PAIRS, encoding="utf-8") as f:
75
+ for line in f:
76
+ line = line.strip()
77
+ if not line:
78
+ continue
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  try:
80
+ r = json.loads(line)
81
  except ValueError:
82
+ continue
83
+ key = (r.get("input", ""), r.get("output", ""))
84
+ if key in seen:
85
+ continue
86
+ seen.add(key)
87
+ rows.append(r)
88
+ except OSError:
89
+ return ""
90
+ stamp = dt.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
91
+ out = os.path.join(paths.DATASET_DIR, f"chan_sft_{stamp}.jsonl")
 
 
 
92
  try:
93
+ with open(out, "w", encoding="utf-8") as f:
94
+ for r in rows:
95
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
96
+ except OSError:
 
 
 
 
 
 
 
 
 
 
97
  return ""
98
+ return out
99
 
100
 
101
+ def status_line() -> str:
102
+ n = count()
103
+ if n == 0:
104
+ return "_No fine-tuning pairs captured yet run a few Signals AI summaries._"
105
+ return f"📚 **{n}** training pair(s) captured on /data (target: 200-500 for a good LoRA)."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
paths.py CHANGED
@@ -1,64 +1,307 @@
1
  """
2
- paths.py — persistent storage layout on the Hugging Face storage bucket (/data).
3
 
4
- If /data exists and is writable (the Space has a storage bucket attached),
5
- everything that should survive restarts/sleep lives there:
6
 
7
- /data/cache_us market-data parquet cache (yfinance downloads)
8
- /data/output signal CSVs, holdings.txt
9
- /data/reports auto-generated research reports (markdown)
10
- /data/traces agent traces (JSON) shareable on the Hub (📡 badge)
11
- /data/hf_cache GGUF model files (downloaded once, kept forever)
12
- /data/pylibs llama-cpp-python installed once at runtime, persisted
13
 
14
- Without a bucket the app still works it just falls back to the container
15
- filesystem (wiped on restart).
 
16
  """
17
  from __future__ import annotations
18
 
19
  import os
20
- import sys
 
21
 
 
 
22
 
23
- def _writable(p: str) -> bool:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  try:
25
- return os.path.isdir(p) and os.access(p, os.W_OK)
26
- except OSError:
27
- return False
 
28
 
29
 
30
- DATA_ROOT = "/data" if _writable("/data") else "."
31
- PERSISTENT = DATA_ROOT == "/data"
 
 
 
 
 
32
 
33
- CACHE_DIR = os.path.join(DATA_ROOT, "cache_us")
34
- OUTPUT_DIR = os.path.join(DATA_ROOT, "output") if PERSISTENT else "./_app_output"
35
- REPORTS_DIR = os.path.join(DATA_ROOT, "reports")
36
- TRACES_DIR = os.path.join(DATA_ROOT, "traces")
37
- DATASET_DIR = os.path.join(DATA_ROOT, "dataset") # SFT training pairs (JSONL)
38
- HF_CACHE_DIR = os.path.join(DATA_ROOT, "hf_cache")
39
- PYLIBS_DIR = os.path.join(DATA_ROOT, "pylibs")
40
 
41
- for _d in (CACHE_DIR, OUTPUT_DIR, REPORTS_DIR, TRACES_DIR, DATASET_DIR, HF_CACHE_DIR, PYLIBS_DIR):
42
  try:
43
- os.makedirs(_d, exist_ok=True)
44
- except OSError:
 
45
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
- # GGUF downloads (hf_hub_download) go to the bucket so models persist
48
- if PERSISTENT:
49
- os.environ.setdefault("HF_HOME", HF_CACHE_DIR)
50
 
51
- # llama-cpp-python installed into the bucket must be importable.
52
- # APPEND (not insert) so bucket-installed copies of common deps (numpy etc.)
53
- # can never shadow the system site-packages the app was built against.
54
- if PERSISTENT and PYLIBS_DIR not in sys.path:
55
- sys.path.append(PYLIBS_DIR)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
 
58
- def storage_status() -> str:
59
- if PERSISTENT:
60
- return ("✅ Persistent storage bucket mounted at `/data` — data cache, "
61
- "model files, reports, traces and the llama.cpp runtime all "
62
- "survive restarts.")
63
- return ("⚠️ No `/data` bucket detected — running on ephemeral container "
64
- "storage (everything re-downloads after a restart).")
 
 
 
 
 
 
1
  """
2
+ llm_local.py — local sub-agent pool (llama.cpp runtime, no cloud APIs).
3
 
4
+ Two independent model instances ("sub-agents"), each with its own lock, so
5
+ features never block each other with "model busy":
6
 
7
+ fast · Summary Sub-Agent (Chan-Tuned Qwen3-1.7B)
8
+ Explain-in-English, sector-rotation narrative, news briefs.
9
+ Small = quick CPU prefill, answers start streaming in seconds.
10
+ deep · AnalystQwen3-4B Q4_K_M by default (swappable in the Model tab)
11
+ the multi-step Auto Research agent's report writing.
 
12
 
13
+ Both run through llama.cpp (llama-cpp-python) and are far below the 32B cap;
14
+ the fast worker doubles as the "Tiny Titan" (≤4B) story. ~5 GB RAM total on a
15
+ 32 GB Space. Earns "Off the Grid" + "Llama Champion".
16
  """
17
  from __future__ import annotations
18
 
19
  import os
20
+ import re
21
+ import threading
22
 
23
+ import paths # sets HF_HOME + sys.path for /data persistence
24
+ from huggingface_hub import hf_hub_download
25
 
26
+ # name -> (HF repo, gguf filename)
27
+ MODEL_ZOO = {
28
+ "Chan-Tuned Qwen3-1.7B · my fine-tune": (
29
+ "ranranrunforit/chan-compass-qwen3-1.7b-gguf", "qwen3-1.7b.Q8_0.gguf"),
30
+ "Qwen3-1.7B · Tiny Titan (≤4B award class)": (
31
+ "Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf"),
32
+ "Qwen3-4B · default — fast + smart, still ≤4B": (
33
+ "Qwen/Qwen3-4B-GGUF", "Qwen3-4B-Q4_K_M.gguf"),
34
+ "Qwen3-8B · best balance on 8 vCPU / 32 GB": (
35
+ "Qwen/Qwen3-8B-GGUF", "Qwen3-8B-Q4_K_M.gguf"),
36
+ "Qwen3-14B · max quality (still far under 32B cap)": (
37
+ "Qwen/Qwen3-14B-GGUF", "Qwen3-14B-Q4_K_M.gguf"),
38
+ }
39
+ # Only the Summary sub-agent (Signals · Explain) uses the published
40
+ # fine-tune; every other sub-agent stays on the stock models.
41
+ FAST_MODEL = "Qwen3-1.7B · Tiny Titan (≤4B award class)"
42
+ TRANSLATOR_MODEL = "Chan-Tuned Qwen3-1.7B · my fine-tune"
43
+ DEFAULT_MODEL = "Qwen3-4B · default — fast + smart, still ≤4B"
44
+
45
+ _THINK_RE = re.compile(r"<think>.*?</think>", re.S)
46
+ _NCPU = max(2, (os.cpu_count() or 4))
47
+
48
+ # One dedicated sub-agent per feature — independent locks, so Signals-Explain,
49
+ # Rotation narrative, News briefs and Auto-Research never fight over a model.
50
+ # Three tiny 1.7B instances share ONE GGUF file on disk (~2 GB RAM each) and
51
+ # the 4B Analyst writes reports. Total ≈ 9 GB on a 32 GB Space.
52
+ WORKER_LABEL = {
53
+ "translator": "Summary sub-agent (Signals · Explain)",
54
+ "narrator": "Narrator sub-agent (Sector Rotation)",
55
+ "reporter": "Reporter sub-agent (News · Research support)",
56
+ "analyst": "Analyst sub-agent (Auto Research)",
57
+ }
58
+
59
+
60
+ def _mk(model):
61
+ return {"model": model, "llm": None, "lock": threading.Lock(),
62
+ "load_lock": threading.Lock(), "stage": "idle", "detail": "", "ts": None}
63
+
64
+
65
+ WORKERS = {
66
+ "translator": _mk(TRANSLATOR_MODEL),
67
+ "narrator": _mk(FAST_MODEL),
68
+ "reporter": _mk(FAST_MODEL),
69
+ "analyst": _mk(DEFAULT_MODEL),
70
+ }
71
+ # legacy aliases
72
+ _ALIAS = {"fast": "translator", "deep": "analyst"}
73
+
74
+
75
+ def _wk(worker: str) -> str:
76
+ return _ALIAS.get(worker, worker)
77
+
78
+ _install_lock = threading.Lock()
79
+
80
+
81
+ def _set_stage(worker: str, stage: str, detail: str = ""):
82
+ import datetime as _dt
83
+ w = WORKERS[worker]
84
+ w.update(stage=stage, detail=detail[:400],
85
+ ts=_dt.datetime.utcnow().strftime("%H:%M:%S UTC"))
86
  try:
87
+ import automation
88
+ automation._log(f"[{WORKER_LABEL[worker]}] {stage}: {detail[:140]}")
89
+ except Exception:
90
+ pass
91
 
92
 
93
+ # ─────────────────────── runtime install (once, persisted) ───────────────────────
94
+ # Installed at RUNTIME, not at Space build time: the HF build container has
95
+ # little RAM and gets OOM-killed compiling the C++ extension; the runtime
96
+ # container has the real hardware. Prebuilt CPU wheel first, capped-parallelism
97
+ # source build as fallback. Persisted to /data/pylibs.
98
+ _WHEEL_INDEX = "https://abetlen.github.io/llama-cpp-python/whl/cpu"
99
+ _LLAMA_REQ = "llama-cpp-python>=0.3.8" # >=0.3.8 → Qwen3 architecture support
100
 
 
 
 
 
 
 
 
101
 
102
+ def _ensure_llama_cpp(worker: str) -> str:
103
  try:
104
+ import llama_cpp # noqa: F401
105
+ return ""
106
+ except ImportError:
107
  pass
108
+ import subprocess
109
+ import sys
110
+ with _install_lock:
111
+ try: # another thread may have finished it while we waited
112
+ import llama_cpp # noqa: F401
113
+ return ""
114
+ except ImportError:
115
+ pass
116
+ env = dict(os.environ)
117
+ env["CMAKE_BUILD_PARALLEL_LEVEL"] = "4"
118
+ _set_stage(worker, "installing llama.cpp runtime",
119
+ "trying official prebuilt CPU wheel (≈1 min)…")
120
+ if paths.PERSISTENT:
121
+ base = [sys.executable, "-m", "pip", "install", "--prefer-binary",
122
+ "--target", paths.PYLIBS_DIR]
123
+ else:
124
+ base = [sys.executable, "-m", "pip", "install", "--user", "--prefer-binary"]
125
+ r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX,
126
+ "--only-binary", "llama-cpp-python", _LLAMA_REQ],
127
+ capture_output=True, text=True, env=env, timeout=600)
128
+ if r.returncode != 0:
129
+ _set_stage(worker, "installing llama.cpp runtime",
130
+ "no prebuilt wheel matched — compiling from source "
131
+ "(one-time ~10-15 min; other tabs keep working)…")
132
+ r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX, _LLAMA_REQ],
133
+ capture_output=True, text=True, env=env, timeout=2400)
134
+ if r.returncode != 0:
135
+ err = (r.stderr or r.stdout or "")[-800:]
136
+ _set_stage(worker, "install FAILED", err)
137
+ return "Could not install llama-cpp-python at runtime:\n" + err
138
+ import importlib
139
+ import site
140
+ cands = [paths.PYLIBS_DIR] if paths.PERSISTENT else []
141
+ usp = site.getusersitepackages()
142
+ cands += usp if isinstance(usp, list) else [usp]
143
+ for p in cands:
144
+ if p and p not in sys.path:
145
+ sys.path.append(p)
146
+ importlib.invalidate_caches()
147
+ try:
148
+ import llama_cpp # noqa: F401
149
+ return ""
150
+ except Exception as e:
151
+ _set_stage(worker, "install FAILED", f"installed but import failed: {e}")
152
+ return f"Installed but import failed: {e}"
153
+
154
+
155
+ # ─────────────────────── loading ───────────────────────
156
+ def load_model(name: str, worker: str = "analyst") -> str:
157
+ worker = _wk(worker)
158
+ """Load a GGUF into a worker slot. Non-blocking: if that worker is already
159
+ installing/loading, returns its live stage instead of hanging the click."""
160
+ w = WORKERS[worker]
161
+ if not w["load_lock"].acquire(timeout=2):
162
+ return (f"⏳ {WORKER_LABEL[worker]} is busy — current stage: "
163
+ f"**{w['stage']}** ({w['detail'] or '…'}). Press “↻ Refresh status”.")
164
+ try:
165
+ if w["llm"] is not None and w["model"] == name:
166
+ return f"Already loaded on {WORKER_LABEL[worker]}: {name}"
167
+ err = _ensure_llama_cpp(worker)
168
+ if err:
169
+ return err
170
+ try:
171
+ from llama_cpp import Llama
172
+ except Exception as e:
173
+ _set_stage(worker, "import FAILED", str(e))
174
+ return f"llama-cpp-python is not available: {e}"
175
+ repo, fname = MODEL_ZOO[name]
176
+ try:
177
+ _set_stage(worker, "downloading GGUF",
178
+ f"{repo}/{fname} (cached on /data after first time)")
179
+ path = hf_hub_download(repo_id=repo, filename=fname)
180
+ except Exception as e:
181
+ _set_stage(worker, "download FAILED", str(e))
182
+ return f"Could not download {repo}/{fname}: {e}"
183
+ try:
184
+ _set_stage(worker, "loading model into RAM", name)
185
+ w["llm"] = None
186
+ small = worker != "analyst"
187
+ w["llm"] = Llama(
188
+ model_path=path,
189
+ n_ctx=4096 if small else 6144,
190
+ n_threads=(4 if small else _NCPU), # leave headroom for parallel agents
191
+ n_threads_batch=(6 if small else _NCPU),
192
+ n_batch=512,
193
+ verbose=False,
194
+ )
195
+ w["model"] = name
196
+ _set_stage(worker, "ready", name)
197
+ return f"✅ {WORKER_LABEL[worker]} ready: {name}"
198
+ except Exception as e:
199
+ w["llm"] = None
200
+ _set_stage(worker, "load FAILED", str(e))
201
+ return f"Failed to load model: {e}"
202
+ finally:
203
+ w["load_lock"].release()
204
 
 
 
 
205
 
206
+ def auto_load_all():
207
+ """Startup: tiny agents first (one small GGUF download serves all three),
208
+ then the Analyst. Runs in a background thread."""
209
+ for key in ("translator", "narrator", "reporter", "analyst"):
210
+ load_model(WORKERS[key]["model"], worker=key)
211
+
212
+
213
+ # ─────────────────────── status ───────────────────────
214
+ def is_loaded(worker: str = None) -> bool:
215
+ if worker:
216
+ return WORKERS[_wk(worker)]["llm"] is not None
217
+ return any(w["llm"] is not None for w in WORKERS.values())
218
+
219
+
220
+ def status() -> str:
221
+ lines = []
222
+ for key in ("translator", "narrator", "reporter", "analyst"):
223
+ w = WORKERS[key]
224
+ label = WORKER_LABEL[key]
225
+ if w["llm"] is not None:
226
+ lines.append(f"✅ **{label}** — {w['model']} · llama.cpp, local")
227
+ elif w["stage"] == "idle":
228
+ lines.append(f"⚪ **{label}** — not loaded yet (auto-loads at startup)")
229
+ else:
230
+ lines.append(f"⏳ **{label}** — {w['stage']} ({w['ts']}): {w['detail'] or '…'}")
231
+ lines.append("\n_Each sub-agent has its own lock — Explain / narrative / "
232
+ "research run in parallel without “model busy”._")
233
+ return "\n\n".join(lines)
234
+
235
+
236
+ # ─────────────────────── inference ───────────────────────
237
+ DEFAULT_SYSTEM = ("You are a sub-agent of Chan Compass, a US-equity dashboard. "
238
+ "Answer in clear, concise English.")
239
+ MAX_PROMPT_CHARS = 3200
240
+
241
+
242
+ def _messages(user: str, system: str):
243
+ return [{"role": "system", "content": system + " /no_think"},
244
+ {"role": "user", "content": user[:MAX_PROMPT_CHARS]}]
245
+
246
+
247
+ def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
248
+ system: str = DEFAULT_SYSTEM, worker: str = "translator") -> str:
249
+ """Blocking chat on one sub-agent (used by pipeline/agent code)."""
250
+ w = WORKERS[_wk(worker)]; worker = _wk(worker)
251
+ if w["llm"] is None:
252
+ return ""
253
+ if not w["lock"].acquire(timeout=180):
254
+ return f"({WORKER_LABEL[worker]} busy — try again in a moment)"
255
+ try:
256
+ out = w["llm"].create_chat_completion(
257
+ messages=_messages(user, system),
258
+ max_tokens=max_tokens, temperature=temperature)
259
+ txt = out["choices"][0]["message"]["content"] or ""
260
+ return _THINK_RE.sub("", txt).strip()
261
+ except Exception as e:
262
+ return f"(model error: {e})"
263
+ finally:
264
+ w["lock"].release()
265
+
266
+
267
+ def chat_stream(user: str, max_tokens: int = 500, temperature: float = 0.3,
268
+ system: str = DEFAULT_SYSTEM, worker: str = "translator"):
269
+ """Streaming chat on one sub-agent — yields cumulative text immediately."""
270
+ w = WORKERS[_wk(worker)]; worker = _wk(worker)
271
+ if w["llm"] is None:
272
+ yield (f"⏳ {WORKER_LABEL[worker]} isn't ready yet — "
273
+ f"stage: {w['stage']}. Check the **Model** tab.")
274
+ return
275
+ if not w["lock"].acquire(timeout=5):
276
+ yield f"⏳ {WORKER_LABEL[worker]} is finishing another answer — try again in a few seconds."
277
+ return
278
+ try:
279
+ acc = ""
280
+ for chunk in w["llm"].create_chat_completion(
281
+ messages=_messages(user, system),
282
+ max_tokens=max_tokens, temperature=temperature, stream=True):
283
+ delta = chunk["choices"][0]["delta"].get("content") or ""
284
+ if not delta:
285
+ continue
286
+ acc += delta
287
+ yield _THINK_RE.sub("", acc).replace("<think>", "").strip()
288
+ if not acc.strip():
289
+ yield "(model returned no text — try again)"
290
+ except Exception as e:
291
+ yield f"(model error: {e})"
292
+ finally:
293
+ w["lock"].release()
294
 
295
 
296
+ def quick_test() -> str:
297
+ """Sanity check both sub-agents."""
298
+ import time
299
+ outs = []
300
+ for key in ("translator", "narrator", "reporter", "analyst"):
301
+ if WORKERS[key]["llm"] is None:
302
+ outs.append(f"{WORKER_LABEL[key]}: not loaded ({WORKERS[key]['stage']})")
303
+ continue
304
+ t0 = time.time()
305
+ out = chat("Reply with exactly: OK", max_tokens=6, temperature=0.0, worker=key)
306
+ outs.append(f"{WORKER_LABEL[key]}: **{out or '(no output)'}** · {time.time()-t0:.1f}s")
307
+ return "\n\n".join(outs)
spacing.css ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ ELEVATION & MOTION — Chan Compass · Spectrum 2
3
+ Soft, low-spread drop shadows; never heavy. Spectrum easing curves.
4
+ ============================================================ */
5
+
6
+ :root {
7
+ /* ---- Drop shadows (Spectrum 2 elevation) ---- */
8
+ --shadow-flat: none;
9
+ --shadow-raised: 0 1px 3px rgba(0,0,0,.06), 0 1px 1px rgba(0,0,0,.04);
10
+ --shadow-elevated: 0 2px 8px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
11
+ --shadow-emphasized:0 6px 20px rgba(0,0,0,.12), 0 2px 6px rgba(0,0,0,.08);
12
+ --shadow-dragged: 0 12px 32px rgba(0,0,0,.18), 0 4px 10px rgba(0,0,0,.10);
13
+
14
+ /* Accent-tinted glow (used on AI / focused panels) */
15
+ --shadow-accent: 0 2px 12px rgba(2,101,220,.12);
16
+
17
+ /* ---- Focus ring ---- */
18
+ --focus-ring-width: 2px;
19
+ --focus-ring-offset: 2px;
20
+
21
+ /* ---- Motion ---- */
22
+ --ease-default: cubic-bezier(0.45, 0, 0.4, 1); /* @kind other */
23
+ --ease-in: cubic-bezier(0.5, 0, 1, 1); /* @kind other */
24
+ --ease-out: cubic-bezier(0, 0, 0.4, 1); /* @kind other */
25
+ --ease-in-out: cubic-bezier(0.45, 0, 0.4, 1); /* @kind other */
26
+
27
+ --duration-fast: 130ms; /* @kind other */
28
+ --duration-default: 160ms; /* @kind other */
29
+ --duration-slow: 220ms; /* @kind other */
30
+
31
+ --transition-default: all var(--duration-default) var(--ease-default);
32
+ --transition-colors: color var(--duration-fast) var(--ease-default),
33
+ background-color var(--duration-fast) var(--ease-default),
34
+ border-color var(--duration-fast) var(--ease-default);
35
+ }
styles.css ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Chan Compass · Spectrum 2 Design System
2
+
3
+ A design system for **Chan Compass · US** — a multi-timeframe 缠论 (Chan theory)
4
+ stock-signal engine — rendered in **Adobe Spectrum 2**, Adobe's design language.
5
+ It exists to give design agents a single, accurate source of truth for building
6
+ beautiful, on-brand Chan Compass interfaces (the live Gradio app, mocks, slides,
7
+ marketing) without re-deriving Spectrum 2 every time.
8
+
9
+ > **Spectrum 2** is Adobe's open design system. This project recreates its
10
+ > visual foundations (color, type, spacing, elevation, motion) and a set of
11
+ > React components, then applies them to the Chan Compass product surfaces.
12
+
13
+ ---
14
+
15
+ ## What the product is
16
+
17
+ Chan Compass is an **educational US-equities signal tool** that runs entirely
18
+ locally. It has six surfaces (Gradio tabs):
19
+
20
+ | Surface | What it does |
21
+ |---|---|
22
+ | **Signals** | Runs the Chan engine over a ticker pool; returns next-session **BUY / SELL / HOLD / WAIT / WATCH** with confidence, suggested weight, stop, and a full multi-timeframe ruling chain (monthly → weekly → daily → 60m → 30m → 15m → 5m nested-interval confirmation). |
23
+ | **Sector Rotation** | Where capital is flowing across the 11 SPDR sector ETFs; flow proxy = change % × dollar volume, plus relative strength vs SPY, over 1 / 5 / 20 days. |
24
+ | **Watchlist News** | Checks **today's** news per holding; pushes an AI brief only when news exists. |
25
+ | **Auto Research** | A local multi-step agent: PLAN → 5 evidence tools → sectioned report, with a saved JSON trace. |
26
+ | **Automation** | Daily pipeline at 18:10 America/New_York. |
27
+ | **Model** | Loads Qwen3 GGUF weights through llama.cpp — everything runs locally, nothing leaves the machine. |
28
+
29
+ The AI is a **local sub-agent pool** (Qwen3-1.7B translator/narrator + a deeper
30
+ analyst), served via `llama-cpp-python`. Data comes from Yahoo Finance.
31
+
32
+ ## Sources this system was built from
33
+
34
+ - **Codebase:** `chan-compass-us-v1.7/` (attached) — a Gradio app (`app.py`)
35
+ plus the Chan engine (`chan_engine.py`, `chan_multilevel.py`, `chan_enhance.py`),
36
+ signal/rotation/news/research/automation modules. The app already ships an
37
+ *approximation* of Spectrum 2 in Gradio CSS; this system replaces it with the
38
+ real thing. The product deploys as a **Hugging Face Gradio Space**.
39
+ - **Design language — Adobe Spectrum 2 / React Spectrum:**
40
+ <https://github.com/adobe/react-spectrum> (the `@react-spectrum/s2` package —
41
+ `style/spectrum-theme.ts`, `style/tokens.ts` — was read for exact spacing,
42
+ radii, easing, weights and the type scale). Token values were cross-checked
43
+ against <https://spectrum.adobe.com/page/color-palette/>.
44
+ Explore that repo to go deeper on component behavior and accessibility.
45
+
46
+ > **Deploying the live app?** See `gradio/` — a ready-to-paste Spectrum 2 Gradio
47
+ > theme (`theme.py` = `THEME` + `CSS`), a guaranteed-green standalone demo Space,
48
+ > and an HF deploy checklist (`gradio/INTEGRATE.md`).
49
+
50
+ ---
51
+
52
+ ## Content fundamentals
53
+
54
+ How Chan Compass writes. Match this voice in any UI copy.
55
+
56
+ - **Voice:** expert, terse, instrument-panel. Copy reads like a trading desk
57
+ tool, not a consumer app. Short noun phrases over sentences: *"Tomorrow's
58
+ plan"*, *"Where capital is flowing"*, *"Buy/sell zone"*.
59
+ - **Person:** mostly impersonal/imperative. Buttons are verbs — *"Run analysis"*,
60
+ *"Check today's news"*, *"Load model"*. When it addresses the user it's second
61
+ person and possessive — *"My holdings"*, *"your ticker pool"*.
62
+ - **Casing:** **Sentence case** everywhere — labels, buttons, headings
63
+ (*"Run analysis"*, not "Run Analysis"). Ticker symbols and ETF codes are
64
+ **UPPERCASE monospace** (NVDA, XLK). Signal verbs render UPPERCASE in pills
65
+ (BUY/SELL/HOLD).
66
+ - **Bilingual register:** the engine's internal ruling chain is **Chinese**
67
+ (缠论 terms: 中枢, 背驰, 区间套, 买卖点 B1/B2/B3/S1/S2/S3); user-facing summaries
68
+ are **English**, produced by the local translator sub-agent. Keep Chinese for
69
+ authentic engine output; keep English for everything the user reads first.
70
+ - **Numbers carry meaning, not decoration.** Every percent, confidence score,
71
+ weight and stop price is real signal. No vanity stats. Percentages are signed
72
+ (+2.43% / −3.08%) and color-coded.
73
+ - **Honesty & disclaimers:** always frames itself as *"educational tool — not
74
+ investment advice."* States its data limits plainly (*"Yahoo only keeps 7 days
75
+ of 1-minute bars, so the 1m level is skipped"*). No hype, no guarantees.
76
+ - **AI is labeled.** Anything model-generated is prefixed with the sub-agent and
77
+ model — *"🤖 Translator sub-agent (Qwen3-1.7B · llama.cpp)"* — and notes
78
+ latency honestly (*"first words in ~5–15s"*).
79
+ - **Emoji:** the live app uses tab emoji (📈 🔄 📰 🧪 ⏰ 🧠) and the 🧭 brand mark.
80
+ In the **polished design system we prefer Spectrum line icons** for UI chrome
81
+ and reserve emoji for the brand mark / casual chips. Don't scatter emoji into
82
+ data or buttons.
83
+
84
+ ---
85
+
86
+ ## Visual foundations
87
+
88
+ The Spectrum 2 look, as applied here.
89
+
90
+ - **Color vibe:** calm, neutral, **light-first**. A near-white canvas
91
+ (`gray-50 #F8F8F8`) with **white cards** (`gray-25`) and crisp hairline borders
92
+ (`gray-100 #E6E6E6`). One disciplined accent — **Spectrum blue `#0265DC`** —
93
+ carries all primary action and selection. Color is otherwise **semantic only**:
94
+ green = positive/BUY, red = negative/SELL, blue = HOLD/accent, orange = WATCH/notice.
95
+ No decorative color, no rainbow dashboards.
96
+ - **Type:** **Source Sans 3** for UI and headings (open stand-in for the
97
+ proprietary Adobe Clean), **Source Serif 4** for editorial/report prose,
98
+ **Source Code Pro** for tickers, prices and logs. Modular scale, ratio **1.125**,
99
+ **14px UI base**. Headings are **bold (700)** and slightly tightened
100
+ (`-0.015em`); body is **regular (400)** at a spacious **1.5** line height.
101
+ - **Spacing:** an **8px base rhythm** (2 · 4 · 8 · 12 · 16 · 24 · 32 · 48 · 64).
102
+ Generous padding inside cards (24px), comfortable 24–32px gaps between regions.
103
+ - **Backgrounds:** **flat, layered surfaces** — no photographic imagery, no
104
+ full-bleed art, no repeating textures. Depth comes from Spectrum's
105
+ *background layering* (canvas → card → subtle well), not from shadows alone.
106
+ The **only gradient** is a small one on the 🧭 brand mark
107
+ (blue → indigo → purple), echoing the app's original hero; the rest is solid.
108
+ - **Corner radii:** soft but not pill-everything — **16px cards**, **8px inputs
109
+ & square buttons**, **4px chips**, and **fully-rounded (pill) action buttons** —
110
+ the single most recognizable Spectrum 2 signature here.
111
+ - **Elevation:** **soft, low-spread** drop shadows (`raised` = `0 1px 3px
112
+ rgba(0,0,0,.06)`), never heavy. Cards sit barely off the canvas. The AI panel
113
+ gets a faint **accent-tinted glow** instead of a darker shadow.
114
+ - **Borders:** 1px hairlines for structure; inputs use a **2px** border that
115
+ turns accent-blue on focus. Tabs sit on a 2px track and the selected tab draws
116
+ a 2px accent underline.
117
+ - **Hover / press:** Spectrum's **one-stop-darker** rule — hover bumps a button
118
+ one color stop darker (`accent → accent-hover`), press goes one more and adds a
119
+ **subtle `scale(.98)`**. Quiet/ghost controls fill with `gray-75` on hover.
120
+ Rows highlight to `gray-75` on hover, `blue-100` when selected.
121
+ - **Focus:** a **2px solid accent ring with 2px offset** on every interactive
122
+ element — visible, never removed.
123
+ - **Motion:** quick and restrained. Durations **130–220ms**; the house easing is
124
+ Spectrum's `cubic-bezier(0.45, 0, 0.4, 1)` (with `ease-out` for entrances).
125
+ Fades and one-stop color shifts; **no bounces**, one tasteful pulse for live
126
+ status and the running pipeline step. Respect `prefers-reduced-motion`.
127
+ - **Transparency / blur:** used sparingly — not a glassmorphism system. Surfaces
128
+ are opaque; selection tints use solid subtle fills (`blue-100`), not alpha.
129
+ - **Cards:** white surface, 16px radius, 1px hairline border, soft raised shadow,
130
+ optional header with title + subtitle + a trailing action slot.
131
+
132
+ ---
133
+
134
+ ## Index — what's in this project
135
+
136
+ **Foundations (root)**
137
+ - `styles.css` — the single entry point consumers link (`@import`s only).
138
+ - `tokens/` — `colors.css` · `typography.css` · `spacing.css` · `elevation.css`
139
+ · `fonts.css` · `base.css`. Primitives + semantic aliases.
140
+ - `guidelines/` — foundation specimen cards (Colors, Type, Spacing) shown in the
141
+ Design System tab.
142
+
143
+ **Components** (`components/<group>/` — React primitives, bundled to `window.<Namespace>`)
144
+ - `actions/` — **Button** (pill, accent/secondary/negative/quiet)
145
+ - `forms/` — **Field**, **Checkbox**, **Switch**
146
+ - `status/` — **Badge**, **StatusLight**, **SignalBadge** (BUY/SELL/HOLD/WAIT/WATCH)
147
+ - `containers/` — **Card**, **InlineAlert** (incl. the signature `ai` panel)
148
+ - `navigation/` — **Tabs** (quiet tabs + accent underline)
149
+
150
+ **UI kit** (`ui_kits/chan-compass/`)
151
+ - `index.html` — full interactive Spectrum 2 recreation of the 6-tab app
152
+ (Signals, Rotation, Research, News, Model). Composes the components above.
153
+
154
+ **Deploy bridge** (`gradio/`)
155
+ - `theme.py` (`THEME` + `CSS`), standalone demo `app.py`, `requirements.txt`,
156
+ `README.md` (HF Space), `INTEGRATE.md` (port + deploy checklist).
157
+
158
+ **Meta**
159
+ - `SKILL.md` — makes this folder usable as an Agent Skill.
160
+
161
+ ---
162
+
163
+ ## Iconography
164
+
165
+ See the **Iconography** section below and the `assets/` README. In short: Chan
166
+ Compass has **no custom icon set** of its own — the live app leans on **emoji**
167
+ for tab affordances and the **🧭 compass** as its brand mark. Spectrum's own
168
+ **workflow-icons** are the canonical reference but aren't freely CDN-hosted, so
169
+ this system substitutes **[Lucide](https://lucide.dev)** — clean 2px line icons
170
+ whose weight and rounded joinery read as Spectrum-adjacent — loaded from CDN in
171
+ the UI kit. **Substitution flagged.** If you license Adobe's Spectrum workflow
172
+ icons, swap Lucide for them and the visual language tightens further.
173
+
174
+ *Educational tool — not investment advice.*
theme.py CHANGED
@@ -1,17 +1,13 @@
1
- # Spectrum 2 theme for Gradio — Chan Compass
2
  # ---------------------------------------------------------------
3
  # Single source of truth for the LIVE Gradio app's look. Mirrors the
4
- # Chan Compass · Spectrum 2 design system (tokens in ../tokens/*.css).
5
- #
6
- # Usage in app.py:
7
  #
8
  # from theme import THEME, CSS
9
  # with gr.Blocks(title="Chan Compass · US", theme=THEME, css=CSS) as demo:
10
  # ...
11
- # demo.launch()
12
- #
13
- # On Gradio >= 6 pass them to launch() instead:
14
- # demo.launch(theme=THEME, css=CSS)
15
  # ---------------------------------------------------------------
16
  import gradio as gr
17
 
@@ -24,6 +20,7 @@ GRAY_25 = "#ffffff"
24
  GRAY_50 = "#f8f8f8" # canvas
25
  GRAY_75 = "#f3f3f3"
26
  GRAY_100 = "#e6e6e6" # hairline border
 
27
  GRAY_300 = "#b1b1b1" # field border
28
  GRAY_500 = "#6d6d6d" # muted text
29
  GRAY_700 = "#292929" # body text
@@ -50,18 +47,25 @@ THEME = gr.themes.Default(
50
  block_radius="16px",
51
  block_shadow="0 1px 3px rgba(0,0,0,.06), 0 1px 1px rgba(0,0,0,.04)",
52
  block_label_text_color=GRAY_500,
 
53
  block_title_text_color=GRAY_800,
 
54
  border_color_primary=GRAY_100,
 
 
55
  # text
56
  body_text_color=GRAY_700,
57
  body_text_color_subdued=GRAY_500,
 
58
  # buttons — Spectrum 2 signature pill
59
  button_large_radius="9999px",
60
  button_small_radius="9999px",
 
61
  button_primary_background_fill=ACCENT,
62
  button_primary_background_fill_hover=ACCENT_HOVER,
63
  button_primary_text_color="#ffffff",
64
  button_primary_border_color=ACCENT,
 
65
  button_secondary_background_fill=GRAY_25,
66
  button_secondary_background_fill_hover=GRAY_75,
67
  button_secondary_border_color=GRAY_300,
@@ -70,7 +74,9 @@ THEME = gr.themes.Default(
70
  input_background_fill=GRAY_25,
71
  input_border_color=GRAY_300,
72
  input_border_color_focus=ACCENT,
 
73
  input_radius="8px",
 
74
  # accents / links
75
  color_accent_soft=ACCENT_SUBTLE,
76
  link_text_color=ACCENT_HOVER,
@@ -80,68 +86,96 @@ THEME = gr.themes.Default(
80
  # ---- Fine-grained CSS the theme object can't express ----------------------
81
  CSS = """
82
  :root{
83
- --s2-accent:#0265dc; --s2-accent-down:#0054b6;
84
- --s2-gray-50:#f8f8f8; --s2-gray-75:#f3f3f3; --s2-gray-100:#e6e6e6;
85
- --s2-gray-300:#b1b1b1; --s2-gray-500:#6d6d6d; --s2-gray-700:#292929; --s2-gray-800:#1b1b1b;
86
  --s2-positive:#007a39; --s2-negative:#d7373f; --s2-notice:#b25309;
87
- --s2-radius-card:16px;
88
  }
89
  body,.gradio-container{ background:var(--s2-gray-50)!important; color:var(--s2-gray-700);
90
  font-family:'Source Sans 3','Adobe Clean',system-ui,sans-serif; }
91
- .gradio-container{ max-width:1280px!important; margin:0 auto!important; }
 
92
 
93
- /* Signature pill buttons scoped to real action buttons, not table chrome */
94
  button.primary, button.secondary, button.lg, button.sm{
95
  border-radius:9999px!important; font-weight:600!important; letter-spacing:0;
96
- transition:background-color .13s cubic-bezier(.45,0,.4,1), transform .13s cubic-bezier(0,0,.4,1); }
 
 
97
  button.primary:active, button.secondary:active{ transform:scale(.98); }
98
  button:focus-visible{ outline:2px solid var(--s2-accent)!important; outline-offset:2px; }
99
  .table-wrap button, table button, .dataframe button, [class*="cell-menu"] button{
100
- border-radius:6px!important; font-weight:500!important; }
101
 
102
- /* Brand header (replaces gradient hero with a clean Spectrum bar) */
103
- #s2-hero{ display:flex; align-items:center; gap:16px;
104
- background:#fff; border:1px solid var(--s2-gray-100); border-radius:20px;
105
- padding:22px 26px; margin-bottom:8px; box-shadow:0 1px 3px rgba(0,0,0,.06); }
106
- #s2-hero .mark{ width:46px; height:46px; flex:none; border-radius:12px; display:grid; place-items:center;
107
- background:linear-gradient(135deg,#0265dc 0%,#5258e4 60%,#7326d3 100%);
108
- color:#fff; font-size:24px; box-shadow:0 2px 12px rgba(2,101,220,.18); }
109
- #s2-hero h1{ margin:0; font-size:24px; font-weight:800; letter-spacing:-.01em; color:var(--s2-gray-800); }
 
110
  #s2-hero h1 span{ font-weight:300; color:var(--s2-gray-500); }
111
- #s2-hero p{ margin:3px 0 0; color:var(--s2-gray-500); font-size:14px; }
112
- #s2-hero .chips{ margin-left:auto; display:flex; flex-wrap:wrap; gap:7px; justify-content:flex-end; max-width:360px; }
113
  #s2-hero .chips span{ background:var(--s2-gray-75); border:1px solid var(--s2-gray-100); border-radius:9999px;
114
- padding:3px 11px; font-size:12px; font-weight:600; color:var(--s2-gray-700); }
115
 
116
- /* Quiet tabs with accent underline */
117
- .tab-nav{ border-bottom:2px solid var(--s2-gray-100)!important; gap:24px; }
118
  .tab-nav button{ border:none!important; background:transparent!important; border-radius:0!important;
119
- font-size:16px!important; font-weight:600!important; color:var(--s2-gray-500)!important;
120
- padding:12px 2px!important; margin-bottom:-2px; }
121
- .tab-nav button.selected{ color:var(--s2-accent)!important;
122
- box-shadow:inset 0 -2px 0 var(--s2-accent)!important; }
 
 
 
 
 
123
 
124
- /* Cards / blocks */
125
- .block{ border:1px solid var(--s2-gray-100)!important; border-radius:var(--s2-radius-card)!important; }
 
 
 
126
 
127
- /* Data tables */
128
- table{ font-size:13.5px!important; }
 
 
 
 
 
129
  thead th{ background:var(--s2-gray-75)!important; font-weight:700!important; text-transform:uppercase;
130
- letter-spacing:.04em; font-size:11.5px!important; color:var(--s2-gray-500)!important; }
131
  tbody td{ border-color:var(--s2-gray-100)!important; }
 
 
 
 
 
 
 
 
132
 
133
- /* Mono log boxes */
134
- #detail-log textarea{ font-family:'Source Code Pro',monospace!important; font-size:12.5px!important; }
 
135
 
136
- /* LLM output panel same framed look as the Pipeline Log box but neutral
137
- (no accent glow). Starts ~half the log's height, grows with content. */
138
- .llm-out{ border:1px solid var(--s2-border,#d5d9e0); border-radius:8px;
139
- background:var(--s2-surface,#fff); padding:12px 16px; min-height:150px;
140
- font-size:14px; line-height:1.5; overflow:auto; }
141
- .llm-out:empty::after{ content:"AI output appears here"; color:#9aa0a6;
142
- font-style:italic; }
143
 
144
- .s2-footnote{ color:var(--s2-gray-500); font-size:12.5px; }
 
 
 
 
145
  """
146
 
147
  __all__ = ["THEME", "CSS"]
 
1
+ # Spectrum 2 theme for Gradio — Chan Compass (v2, refined)
2
  # ---------------------------------------------------------------
3
  # Single source of truth for the LIVE Gradio app's look. Mirrors the
4
+ # Chan Compass · Spectrum 2 design system. Drop this next to app.py.
 
 
5
  #
6
  # from theme import THEME, CSS
7
  # with gr.Blocks(title="Chan Compass · US", theme=THEME, css=CSS) as demo:
8
  # ...
9
+ # demo.launch() # Gradio 5
10
+ # # Gradio 6+: demo.launch(theme=THEME, css=CSS)
 
 
11
  # ---------------------------------------------------------------
12
  import gradio as gr
13
 
 
20
  GRAY_50 = "#f8f8f8" # canvas
21
  GRAY_75 = "#f3f3f3"
22
  GRAY_100 = "#e6e6e6" # hairline border
23
+ GRAY_200 = "#d5d5d5"
24
  GRAY_300 = "#b1b1b1" # field border
25
  GRAY_500 = "#6d6d6d" # muted text
26
  GRAY_700 = "#292929" # body text
 
47
  block_radius="16px",
48
  block_shadow="0 1px 3px rgba(0,0,0,.06), 0 1px 1px rgba(0,0,0,.04)",
49
  block_label_text_color=GRAY_500,
50
+ block_label_text_weight="600",
51
  block_title_text_color=GRAY_800,
52
+ block_title_text_weight="700",
53
  border_color_primary=GRAY_100,
54
+ panel_background_fill=GRAY_25,
55
+ panel_border_color=GRAY_100,
56
  # text
57
  body_text_color=GRAY_700,
58
  body_text_color_subdued=GRAY_500,
59
+ body_text_size="14px",
60
  # buttons — Spectrum 2 signature pill
61
  button_large_radius="9999px",
62
  button_small_radius="9999px",
63
+ button_large_padding="10px 22px",
64
  button_primary_background_fill=ACCENT,
65
  button_primary_background_fill_hover=ACCENT_HOVER,
66
  button_primary_text_color="#ffffff",
67
  button_primary_border_color=ACCENT,
68
+ button_primary_shadow="none",
69
  button_secondary_background_fill=GRAY_25,
70
  button_secondary_background_fill_hover=GRAY_75,
71
  button_secondary_border_color=GRAY_300,
 
74
  input_background_fill=GRAY_25,
75
  input_border_color=GRAY_300,
76
  input_border_color_focus=ACCENT,
77
+ input_border_width="2px",
78
  input_radius="8px",
79
+ input_shadow="none",
80
  # accents / links
81
  color_accent_soft=ACCENT_SUBTLE,
82
  link_text_color=ACCENT_HOVER,
 
86
  # ---- Fine-grained CSS the theme object can't express ----------------------
87
  CSS = """
88
  :root{
89
+ --s2-accent:#0265dc; --s2-accent-hover:#0054b6; --s2-accent-down:#00418f; --s2-accent-subtle:#e0f2ff;
90
+ --s2-gray-25:#fff; --s2-gray-50:#f8f8f8; --s2-gray-75:#f3f3f3; --s2-gray-100:#e6e6e6;
91
+ --s2-gray-200:#d5d5d5; --s2-gray-300:#b1b1b1; --s2-gray-500:#6d6d6d; --s2-gray-700:#292929; --s2-gray-800:#1b1b1b;
92
  --s2-positive:#007a39; --s2-negative:#d7373f; --s2-notice:#b25309;
93
+ --s2-radius-card:16px; --s2-ease:cubic-bezier(.45,0,.4,1);
94
  }
95
  body,.gradio-container{ background:var(--s2-gray-50)!important; color:var(--s2-gray-700);
96
  font-family:'Source Sans 3','Adobe Clean',system-ui,sans-serif; }
97
+ .gradio-container{ max-width:1200px!important; margin:0 auto!important; padding-top:8px!important; }
98
+ .gap{ gap:14px; }
99
 
100
+ /* ---------- Buttons : Spectrum 2 pill ---------- */
101
  button.primary, button.secondary, button.lg, button.sm{
102
  border-radius:9999px!important; font-weight:600!important; letter-spacing:0;
103
+ transition:background-color .13s var(--s2-ease), transform .12s var(--s2-ease), box-shadow .13s var(--s2-ease); }
104
+ button.primary{ box-shadow:0 1px 2px rgba(2,101,220,.18)!important; }
105
+ button.primary:hover{ box-shadow:0 2px 8px rgba(2,101,220,.22)!important; }
106
  button.primary:active, button.secondary:active{ transform:scale(.98); }
107
  button:focus-visible{ outline:2px solid var(--s2-accent)!important; outline-offset:2px; }
108
  .table-wrap button, table button, .dataframe button, [class*="cell-menu"] button{
109
+ border-radius:6px!important; font-weight:500!important; box-shadow:none!important; }
110
 
111
+ /* ---------- Hero ---------- */
112
+ #s2-hero{ display:flex; align-items:center; gap:18px;
113
+ background:linear-gradient(180deg,#fff 0%,#fcfdff 100%);
114
+ border:1px solid var(--s2-gray-100); border-radius:20px;
115
+ padding:22px 26px; margin:6px 0 14px; box-shadow:0 1px 3px rgba(0,0,0,.05); }
116
+ #s2-hero .mark{ width:50px; height:50px; flex:none; border-radius:14px; display:grid; place-items:center;
117
+ background:linear-gradient(135deg,#0265dc 0%,#5258e4 58%,#7326d3 100%);
118
+ color:#fff; font-size:26px; box-shadow:0 4px 14px rgba(2,101,220,.22); }
119
+ #s2-hero h1{ margin:0; font-size:25px; font-weight:800; letter-spacing:-.015em; color:var(--s2-gray-800); }
120
  #s2-hero h1 span{ font-weight:300; color:var(--s2-gray-500); }
121
+ #s2-hero p{ margin:4px 0 0; color:var(--s2-gray-500); font-size:13.5px; line-height:1.45; max-width:620px; }
122
+ #s2-hero .chips{ margin-left:auto; display:flex; flex-wrap:wrap; gap:6px; justify-content:flex-end; max-width:330px; }
123
  #s2-hero .chips span{ background:var(--s2-gray-75); border:1px solid var(--s2-gray-100); border-radius:9999px;
124
+ padding:3px 11px; font-size:11.5px; font-weight:600; color:var(--s2-gray-700); white-space:nowrap; }
125
 
126
+ /* ---------- Tabs : quiet + accent underline ---------- */
127
+ .tab-nav{ border-bottom:2px solid var(--s2-gray-100)!important; gap:22px; margin-bottom:6px; }
128
  .tab-nav button{ border:none!important; background:transparent!important; border-radius:0!important;
129
+ font-size:15.5px!important; font-weight:600!important; color:var(--s2-gray-500)!important;
130
+ padding:11px 2px!important; margin-bottom:-2px; transition:color .13s var(--s2-ease); }
131
+ .tab-nav button:hover{ color:var(--s2-gray-700)!important; }
132
+ .tab-nav button.selected{ color:var(--s2-accent)!important; box-shadow:inset 0 -2px 0 var(--s2-accent)!important; }
133
+
134
+ /* ---------- Cards / groups ---------- */
135
+ .block{ border-radius:var(--s2-radius-card)!important; }
136
+ .gr-group, .group{ border:1px solid var(--s2-gray-100)!important; border-radius:var(--s2-radius-card)!important;
137
+ background:#fff; overflow:hidden; }
138
 
139
+ /* control bar the primary input row of each tab, framed as a card */
140
+ #s2-app .s2-bar{ background:#fff!important; border:1px solid var(--s2-gray-100)!important;
141
+ border-radius:14px!important; padding:14px 16px!important; box-shadow:0 1px 3px rgba(0,0,0,.05);
142
+ align-items:flex-end!important; }
143
+ #s2-app .s2-bar .block{ border:none!important; box-shadow:none!important; background:transparent!important; }
144
 
145
+ /* eyebrow section labels */
146
+ .s2-eyebrow p{ margin:14px 0 2px!important; font-size:11px!important; font-weight:700!important;
147
+ letter-spacing:.07em; text-transform:uppercase; color:var(--s2-gray-500)!important; }
148
+ .s2-help p{ color:var(--s2-gray-500)!important; font-size:13px!important; line-height:1.5; margin:2px 0 6px!important; }
149
+
150
+ /* ---------- Data tables ---------- */
151
+ .dataframe, table{ font-size:13.5px!important; }
152
  thead th{ background:var(--s2-gray-75)!important; font-weight:700!important; text-transform:uppercase;
153
+ letter-spacing:.04em; font-size:11px!important; color:var(--s2-gray-500)!important; }
154
  tbody td{ border-color:var(--s2-gray-100)!important; }
155
+ tbody tr:hover td{ background:var(--s2-gray-75)!important; }
156
+
157
+ /* ---------- AI output panel — signature accent-framed surface ---------- */
158
+ .llm-out{ border:1px solid var(--s2-gray-100); border-left:5px solid var(--s2-accent); border-radius:12px;
159
+ background:#fff; padding:16px 20px; min-height:140px; font-size:14.5px; line-height:1.6;
160
+ overflow:auto; box-shadow:0 2px 12px rgba(2,101,220,.07); }
161
+ .llm-out:empty::after{ content:"🤖 AI output appears here"; color:#9aa0a6; font-style:italic; }
162
+ .llm-out h1,.llm-out h2,.llm-out h3{ font-size:16px; margin:.4em 0 .3em; color:var(--s2-gray-800); }
163
 
164
+ /* raw-read (non-AI) markdown reads as a quiet well */
165
+ #detail-log textarea{ font-family:'Source Code Pro',monospace!important; font-size:12.5px!important;
166
+ background:var(--s2-gray-75)!important; border-radius:10px!important; }
167
 
168
+ /* ---------- Accordion (email rows, collapsible) ---------- */
169
+ .gr-accordion, .accordion{ border:1px solid var(--s2-gray-100)!important; border-radius:12px!important;
170
+ background:#fff; overflow:hidden; }
171
+ .gr-accordion > button, .accordion > button{ font-weight:600!important; color:var(--s2-gray-700)!important;
172
+ font-size:13.5px!important; }
 
 
173
 
174
+ /* ---------- Misc ---------- */
175
+ .s2-footnote p{ color:var(--s2-gray-500)!important; font-size:12.5px!important; }
176
+ hr{ border:none; border-top:1px solid var(--s2-gray-100); margin:18px 0; }
177
+ label span{ font-weight:600; }
178
+ ::selection{ background:var(--s2-accent-subtle); }
179
  """
180
 
181
  __all__ = ["THEME", "CSS"]
typography.css ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ============================================================
2
+ FONTS — Chan Compass · Spectrum 2
3
+ Adobe Clean (Spectrum's UI typeface) is proprietary, so this
4
+ system substitutes Adobe's own open-source families:
5
+ · Source Sans 3 → UI / headings (stands in for Adobe Clean)
6
+ · Source Serif 4 → editorial serif (stands in for Adobe Clean Serif)
7
+ · Source Code Pro → mono / code (Spectrum's real code font)
8
+ Loaded from Google Fonts. If you have licensed Adobe Clean,
9
+ swap the @font-face stack and the families resolve automatically.
10
+ ============================================================ */
11
+
12
+ @import url('https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&family=Source+Serif+4:ital,wght@0,400;0,600;0,700;1,400&family=Source+Code+Pro:wght@400;500;600&display=swap');