ranranrunforit commited on
Commit
ae8671d
·
verified ·
1 Parent(s): 1c06038

Upload 18 files

Browse files
Files changed (15) hide show
  1. app.py +212 -320
  2. automation.py +346 -137
  3. chan_engine.py +141 -1445
  4. chan_enhance.py +1441 -113
  5. chan_finetune_colab.ipynb +126 -0
  6. chan_glue.py +118 -65
  7. chan_multilevel.py +63 -877
  8. data_us.py +864 -127
  9. finetune_data.py +147 -0
  10. llm_local.py +78 -280
  11. news_watch.py +284 -162
  12. paths.py +175 -49
  13. requirements.txt +64 -7
  14. research.py +7 -121
  15. research_agent.py +94 -397
app.py CHANGED
@@ -1,320 +1,212 @@
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 llm_local
21
- import news_watch
22
- import paths
23
- import research_agent
24
- import rotation
25
- import signal_runner
26
-
27
- # ─────────────────────────────────────────── Spectrum 2 theme ──
28
- # Single source of truth: theme.py from the Chan Compass · Spectrum 2 design
29
- # system (tokens mirrored from the design package's tokens/*.css).
30
- from theme import THEME as theme, CSS as S2_CSS
31
-
32
- # ─────────────────────────────────────────── UI callbacks ──
33
- def ui_run_signals(tickers_text, force):
34
- """Signals ONLY — pure rule engine, zero LLM, parallel downloads. Fast."""
35
- import time
36
- t0 = time.time()
37
- tickers = [t for t in tickers_text.replace("\n", ",").split(",") if t.strip()]
38
- df, details, summary, errors = signal_runner.run_signals(tickers or None,
39
- force=bool(force))
40
- if errors:
41
- summary += " · some tickers skipped (data not ready yet — run again)"
42
- automation.STATE["signals_df"] = df
43
- automation.STATE["signals_details"] = details
44
- automation.STATE["signals_summary"] = summary
45
- choices = sorted(details.keys())
46
- return (
47
- df if df is not None else pd.DataFrame(),
48
- f"{summary} · {time.time()-t0:.1f}s (rule engine only — no LLM in this path)",
49
- gr.update(choices=choices, value=(choices[0] if choices else None)),
50
- )
51
-
52
-
53
- def ui_show_detail(ticker):
54
- if not ticker:
55
- return "Select a ticker after running the analysis."
56
- raw = signal_runner.stock_raw_read(ticker)
57
- if not raw:
58
- return "No data for this ticker yet — run the analysis first."
59
- return f"**Raw read:**\n\n{raw}"
60
-
61
-
62
- def ui_explain_detail(ticker):
63
- raw = signal_runner.stock_raw_read(ticker or "")
64
- if not raw:
65
- yield "Run the analysis and select a ticker first."
66
- return
67
- # Same pattern as Sector Rotation: feed the deterministic ENGLISH raw read
68
- # to the agent — no Chinese in, no Chinese out, fast (short prompt).
69
- prompt = ("You are an equity analyst. Based ONLY on this factual read of a US "
70
- "stock's multi-timeframe Chan-theory verdict, write a short plain-"
71
- "English summary for a long-term holder: what's the situation today, "
72
- "should they act or wait, and the key price levels. ≤90 words, no "
73
- "disclaimers.\n\nRAW READ:\n" + raw)
74
- yield "🤖 _Translator sub-agent (Qwen3-1.7B · llama.cpp) is summarizing…_"
75
- for acc in llm_local.chat_stream(prompt, max_tokens=240, temperature=0.2,
76
- worker="translator"):
77
- yield "🤖 **AI narrative (Translator sub-agent · Qwen3-1.7B):**\n\n" + acc
78
-
79
-
80
- def ui_refresh_rotation():
81
- """Tables only — instant. AI narrative is a separate on-demand button."""
82
- d1, d5, d20, asof = rotation.build_rotation(force=True)
83
- automation.STATE["rotation"] = (d1, d5, d20, asof)
84
- raw = rotation.rotation_brief(d1, d5, d20)
85
- automation.STATE["rotation_narrative"] = raw
86
- return (rotation.fmt_table(d1), rotation.fmt_table(d5), rotation.fmt_table(d20),
87
- f"Sector flows as of **{asof}**", f"**Raw read:**\n{raw}")
88
-
89
-
90
- def ui_rotation_ai():
91
- d1, d5, d20, asof = automation.STATE["rotation"]
92
- if d1 is None:
93
- yield "Refresh the rotation tables first."
94
- return
95
- brief = rotation.rotation_brief(d1, d5, d20)
96
- prompt = ("You are a US equity market strategist. Based only on the sector flow "
97
- "data below (SPDR ETF proxy: change% × dollar volume, plus RS vs SPY), "
98
- "write a crisp brief (<150 words): 1) where capital is rotating INTO/OUT "
99
- "OF; 2) do 1-day moves agree with the 5/20-day trend; 3) one watch item. "
100
- "No disclaimers.\n\nDATA:\n" + brief[:2200])
101
- yield ("🤖 _Narrator sub-agent (Qwen3-1.7B · llama.cpp) is reading the flow "
102
- "tables — first words in ~5-15s…_")
103
- for acc in llm_local.chat_stream(prompt, max_tokens=340, worker="narrator"):
104
- yield "🤖 **Narrator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
105
-
106
-
107
- def ui_save_holdings(text):
108
- saved = news_watch.save_holdings(text.replace("\n", ",").split(","))
109
- return f"Saved {len(saved)} holding(s): {', '.join(saved) if saved else ''}"
110
-
111
-
112
- def ui_check_news():
113
- last = ""
114
- for md in news_watch.check_holdings_news_stream():
115
- last = md
116
- yield md
117
- automation.STATE["news_md"] = last
118
-
119
-
120
- def ui_research(ticker):
121
- for progress, report in research_agent.run_research_stream(ticker):
122
- yield progress, report, gr.update()
123
- yield progress, report, gr.update(choices=research_agent.list_reports())
124
-
125
-
126
- def ui_open_report(fname):
127
- return research_agent.read_report(fname)
128
-
129
-
130
- def ui_load_model(name):
131
- return llm_local.load_model(name, worker="deep")
132
-
133
-
134
- def ui_automation_panel():
135
- return automation.schedule_info(), "\n".join(automation.STATE["log"][-30:]) or "(no log yet)"
136
-
137
-
138
- _SELFTEST = {"done": False, "result": ""}
139
-
140
-
141
- def _auto_selftest():
142
- """Runs once, automatically, the moment every sub-agent is loaded — shows a
143
- self-test verdict without the user clicking anything."""
144
- if _SELFTEST["done"]:
145
- return _SELFTEST["result"]
146
- workers = llm_local.WORKERS
147
- if not all(w["llm"] is not None for w in workers.values()):
148
- ready = sum(1 for w in workers.values() if w["llm"] is not None)
149
- return f" Loading sub-agents… {ready}/{len(workers)} ready (self-test will run automatically)."
150
- out = llm_local.quick_test()
151
- ok = "not loaded" not in out and "error" not in out.lower()
152
- _SELFTEST["done"] = True
153
- _SELFTEST["result"] = (("✅ **Every agent is OK now** — self-test passed:\n\n" + out)
154
- if ok else ("⚠️ Self-test finished with issues:\n\n" + out))
155
- return _SELFTEST["result"]
156
-
157
-
158
- # ─────────────────────────────────────────── layout ──
159
- _GR_MAJOR = int(gr.__version__.split(".")[0])
160
- _style_kw = {} if _GR_MAJOR >= 6 else {"theme": theme, "css": S2_CSS}
161
-
162
- with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
163
- gr.HTML("""
164
- <div id="s2-hero">
165
- <div class="mark">🧭</div>
166
- <div>
167
- <h1>Chan Compass <span>· US Markets</span></h1>
168
- <p>Multi-timeframe 缠论 (Chan theory) engine — monthly → 1m nested-interval
169
- confirmation · sector rotation · local sub-agent pool (llama.cpp).</p>
170
- </div>
171
- <div class="chips">
172
- <span>🧠 Local · no cloud APIs</span><span>🦙 llama.cpp</span>
173
- <span>🤖 4 sub-agents · 1.7B+4B</span><span>📊 Yahoo Finance</span>
174
- <span>⏰ 18:10 ET</span><span>💾 /data bucket</span><span>🎨 Spectrum 2</span>
175
- </div>
176
- </div>""")
177
-
178
- with gr.Tab("📈 Signals"):
179
- with gr.Row():
180
- tickers_in = gr.Textbox(value=", ".join(signal_runner.DEFAULT_POOL),
181
- label="Ticker pool (comma separated)", scale=4)
182
- force_cb = gr.Checkbox(value=False, label="Force fresh download", scale=1)
183
- run_btn = gr.Button("▶ Run analysis", variant="primary", scale=1)
184
- sig_summary = gr.Markdown(automation.STATE["signals_summary"])
185
- sig_table = gr.Dataframe(label="Tomorrow's plan — long-hold mode (sorted: BUY → SELL → HOLD → WAIT)",
186
- interactive=False, wrap=True)
187
- gr.Markdown("**Stock summary** pick a ticker for a plain-English raw read, "
188
- "then let the Translator sub-agent write an AI narrative.",
189
- elem_classes=["s2-footnote"])
190
- with gr.Row():
191
- detail_pick = gr.Dropdown(choices=[], label="Ticker", scale=2)
192
- explain_btn = gr.Button("🤖 AI summary (local LLM)", scale=1)
193
- detail_box = gr.Markdown(label="Raw read")
194
- explain_box = gr.Markdown(elem_classes=["ai-panel"])
195
-
196
- with gr.Tab("🔄 Sector Rotation"):
197
- gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500 coverage). "
198
- "**Flow proxy = price change % × dollar volume**; RS = return minus SPY. "
199
- "True per-sector fund-flow feeds are paid data this is the standard free proxy.")
200
- with gr.Row():
201
- rot_btn = gr.Button("↻ Refresh rotation (instant)", variant="primary")
202
- rot_ai_btn = gr.Button("🤖 AI narrative (local LLM)")
203
- rot_asof = gr.Markdown("Press refresh (or Run analysis on the Signals tab).")
204
- with gr.Row():
205
- rot_1d = gr.Dataframe(label="1-Day (today's rotation)", interactive=False)
206
- with gr.Row():
207
- rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
208
- rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
209
- rot_ai = gr.Markdown(label="AI rotation narrative", elem_classes=["ai-panel"])
210
-
211
- with gr.Tab("📰 Watchlist News"):
212
- gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
213
- "News found → AI brief is pushed below. No news → the ticker is ignored "
214
- "(listed under *Quiet today*).")
215
- with gr.Row():
216
- hold_in = gr.Textbox(value=", ".join(news_watch.load_holdings()),
217
- label="My holdings (comma separated)", scale=3)
218
- save_btn = gr.Button("💾 Save holdings", scale=1)
219
- news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
220
- hold_status = gr.Markdown()
221
- news_out = gr.Markdown(elem_classes=["ai-panel"])
222
-
223
- with gr.Tab("🧪 Auto Research"):
224
- gr.Markdown("**Multi-step research agent** (fully local): PLAN → 5 evidence tools "
225
- "(fundamentals · quarterly financials · price action · **the Chan engine "
226
- "itself** · news) → section-by-section analysis → report. Every step is "
227
- "logged to a JSON **agent trace** on persistent storage. "
228
- "New tickers entering the signal pool get a report **auto-generated** "
229
- "by the daily pipeline.")
230
- with gr.Row():
231
- res_in = gr.Textbox(label="Ticker", placeholder="e.g. NVDA", scale=3)
232
- res_btn = gr.Button("🤖 Run research agent", variant="primary", scale=1)
233
- res_progress = gr.Markdown()
234
- res_out = gr.Markdown(elem_classes=["ai-panel"])
235
- gr.Markdown("**Report library** (auto + manual, stored on `/data`):",
236
- elem_classes=["s2-footnote"])
237
- with gr.Row():
238
- rep_pick = gr.Dropdown(choices=research_agent.list_reports(),
239
- label="Saved reports", scale=3)
240
- rep_open = gr.Button("📂 Open report", scale=1)
241
- rep_view = gr.Markdown(elem_classes=["ai-panel"])
242
-
243
- with gr.Tab("⏰ Automation"):
244
- gr.Markdown(paths.storage_status())
245
- auto_md = gr.Markdown(automation.schedule_info())
246
- with gr.Row():
247
- auto_now = gr.Button("⚡ Run now", variant="primary")
248
- auto_msg = gr.Markdown()
249
- auto_log = gr.Textbox(lines=14, label="Pipeline log (live — updates every 2s)",
250
- elem_id="detail-log")
251
- traces_md = gr.Markdown(research_agent.list_traces())
252
- auto_log_timer = gr.Timer(2.0)
253
- auto_log_timer.tick(lambda: "\n".join(automation.STATE["log"][-40:])
254
- or "(no log yet)", None, auto_log)
255
-
256
- with gr.Tab("🧠 Model"):
257
- gr.Markdown("All AI runs **locally** through **llama.cpp** (llama-cpp-python) with "
258
- "Qwen3 GGUF weights — every option is far below the 32B-parameter cap, "
259
- "and nothing leaves the machine. **First load installs the llama.cpp "
260
- "runtime + downloads the GGUF (one-time, usually 1–3 min; worst case "
261
- "~15 min if it has to compile).** Signals/rotation/news never depend on it.")
262
- gr.Markdown("**Sub-agent pool:** `fast` Translator/Narrator (Qwen3-1.7B, "
263
- "fixed) handles Explain / rotation narrative / news briefs; "
264
- "`deep` Analyst writes research reports. Each has its own lock — "
265
- "they run in parallel. Pick the Analyst model below:")
266
- model_pick = gr.Radio(choices=list(llm_local.MODEL_ZOO.keys()),
267
- value=llm_local.DEFAULT_MODEL, label="Analyst (deep) model")
268
- with gr.Row():
269
- load_btn = gr.Button("⬇ Load model", variant="primary")
270
- test_btn = gr.Button("⚡ Test sub-agents now")
271
- model_status = gr.Markdown(llm_local.status())
272
- model_test_out = gr.Markdown()
273
- model_timer = gr.Timer(2.0)
274
- model_timer.tick(lambda: llm_local.status(), None, model_status)
275
- autotest_timer = gr.Timer(3.0)
276
- autotest_timer.tick(_auto_selftest, None, model_test_out)
277
-
278
- gr.Markdown("Chan Compass · educational tool, not investment advice · "
279
- "data: Yahoo Finance · design language: Adobe Spectrum 2",
280
- elem_classes=["s2-footnote"])
281
-
282
- # wiring
283
- run_btn.click(ui_run_signals, [tickers_in, force_cb],
284
- [sig_table, sig_summary, detail_pick])
285
- detail_pick.change(ui_show_detail, detail_pick, detail_box)
286
- explain_btn.click(ui_explain_detail, detail_pick, explain_box)
287
- rot_btn.click(ui_refresh_rotation, None, [rot_1d, rot_5d, rot_20d, rot_asof, rot_ai])
288
- rot_ai_btn.click(ui_rotation_ai, None, rot_ai)
289
- save_btn.click(ui_save_holdings, hold_in, hold_status)
290
- news_btn.click(ui_check_news, None, news_out)
291
- res_btn.click(ui_research, res_in, [res_progress, res_out, rep_pick])
292
- rep_open.click(ui_open_report, rep_pick, rep_view)
293
- auto_now.click(lambda: automation.run_pipeline(force=True), None, auto_msg)
294
- load_btn.click(ui_load_model, model_pick, model_status)
295
- test_btn.click(lambda: llm_local.quick_test(), None, model_test_out)
296
-
297
- automation.start_scheduler()
298
-
299
- # Auto-load the default local model in the background so AI features (English
300
- # explanations, rotation narrative, research agent) are ready without a click.
301
- def _auto_load_model():
302
- try:
303
- automation._log("Auto-loading sub-agents (llama.cpp)…")
304
- llm_local.auto_load_all()
305
- except Exception as e:
306
- automation._log(f"Sub-agent auto-load failed: {e}")
307
-
308
- try:
309
- import paths as _paths
310
- automation._log(_paths.storage_status())
311
- except Exception:
312
- pass
313
- if os.environ.get("AUTO_LOAD_MODEL", "1") == "1":
314
- threading.Thread(target=_auto_load_model, daemon=True).start()
315
-
316
- if __name__ == "__main__":
317
- if _GR_MAJOR >= 6:
318
- demo.launch(theme=theme, css=S2_CSS)
319
- else:
320
- demo.launch()
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Chan Compass fine-tune Qwen3-1.7B (LoRA) GGUF Hugging Face\n",
8
+ "\n",
9
+ "Runs on a **free Colab T4**. End to end 25-40 min.\n",
10
+ "\n",
11
+ "1. Upload your exported `chan_sft_*.jsonl` (from the app's Model tab → Export dataset).\n",
12
+ "2. Runtime → Change runtime type → **T4 GPU**.\n",
13
+ "3. Run every cell top to bottom.\n",
14
+ "4. Paste your HF token when asked (Settings → Access Tokens, role **write**).\n",
15
+ "\n",
16
+ "Output: a public repo `<you>/chan-compass-qwen3-1.7b-gguf` with a Q8_0 GGUF — the 🎯 Well-Tuned model."
17
+ ]
18
+ },
19
+ {
20
+ "cell_type": "markdown",
21
+ "metadata": {},
22
+ "source": ["## 1 · Install Unsloth (fast LoRA) + tooling"]
23
+ },
24
+ {
25
+ "cell_type": "code",
26
+ "execution_count": null,
27
+ "metadata": {},
28
+ "outputs": [],
29
+ "source": [
30
+ "%%capture\n",
31
+ "!pip install -q \"unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git\"\n",
32
+ "!pip install -q --no-deps \"trl<0.9.0\" peft accelerate bitsandbytes\n",
33
+ "!pip install -q huggingface_hub"
34
+ ]
35
+ },
36
+ {
37
+ "cell_type": "markdown",
38
+ "metadata": {},
39
+ "source": ["## 2 · Upload your dataset (chan_sft_*.jsonl)"]
40
+ },
41
+ {
42
+ "cell_type": "code",
43
+ "execution_count": null,
44
+ "metadata": {},
45
+ "outputs": [],
46
+ "source": [
47
+ "from google.colab import files\n",
48
+ "up = files.upload() # pick your chan_sft_*.jsonl\n",
49
+ "DATA_PATH = list(up.keys())[0]\n",
50
+ "import json\n",
51
+ "rows = [json.loads(l) for l in open(DATA_PATH) if l.strip()]\n",
52
+ "print(f'Loaded {len(rows)} pairs')\n",
53
+ "print('Example output:\\n', rows[0]['output'][:300])"
54
+ ]
55
+ },
56
+ {
57
+ "cell_type": "markdown",
58
+ "metadata": {},
59
+ "source": [
60
+ "If you have fewer than ~150 pairs, run more **AI summaries** in the app first.\n",
61
+ "150-500 focused pairs is plenty for a single-skill LoRA."
62
+ ]
63
+ },
64
+ {
65
+ "cell_type": "markdown",
66
+ "metadata": {},
67
+ "source": ["## 3 · Load Qwen3-1.7B in 4-bit + attach LoRA adapters"]
68
+ },
69
+ {
70
+ "cell_type": "code",
71
+ "execution_count": null,
72
+ "metadata": {},
73
+ "outputs": [],
74
+ "source": [
75
+ "from unsloth import FastLanguageModel\n",
76
+ "import torch\n",
77
+ "\n",
78
+ "MAXLEN = 2048\n",
79
+ "model, tokenizer = FastLanguageModel.from_pretrained(\n",
80
+ " model_name = 'unsloth/Qwen3-1.7B', # base; Unsloth mirror loads fast\n",
81
+ " max_seq_length = MAXLEN,\n",
82
+ " load_in_4bit = True,\n",
83
+ ")\n",
84
+ "model = FastLanguageModel.get_peft_model(\n",
85
+ " model, r = 16, lora_alpha = 16, lora_dropout = 0.0, bias = 'none',\n",
86
+ " target_modules = ['q_proj','k_proj','v_proj','o_proj',\n",
87
+ " 'gate_proj','up_proj','down_proj'],\n",
88
+ " use_gradient_checkpointing = 'unsloth', random_state = 42,\n",
89
+ ")"
90
+ ]
91
+ },
92
+ {
93
+ "cell_type": "markdown",
94
+ "metadata": {},
95
+ "source": ["## 4 · Format pairs into the Qwen chat template"]
96
+ },
97
+ {
98
+ "cell_type": "code",
99
+ "execution_count": null,
100
+ "metadata": {},
101
+ "outputs": [],
102
+ "source": [
103
+ "from datasets import Dataset\n",
104
+ "\n",
105
+ "def to_text(r):\n",
106
+ " msgs = [\n",
107
+ " {'role':'system','content': r['instruction']},\n",
108
+ " {'role':'user','content': r['input']},\n",
109
+ " {'role':'assistant','content': r['output']},\n",
110
+ " ]\n",
111
+ " return tokenizer.apply_chat_template(msgs, tokenize=False,\n",
112
+ " add_generation_prompt=False)\n",
113
+ "\n",
114
+ "ds = Dataset.from_list([{'text': to_text(r)} for r in rows])\n",
115
+ "print(ds[0]['text'][:400])"
116
+ ]
117
+ },
118
+ {
119
+ "cell_type": "markdown",
120
+ "metadata": {},
121
+ "source": ["## 5 · Train (LoRA SFT) — ~10-20 min on T4"]
122
+ },
123
+ {
124
+ "cell_type": "code",
125
+ "execution_count": null,
126
+ "metadata": {},
127
+ "outputs": [],
128
+ "source": [
129
+ "from trl import SFTTrainer\n",
130
+ "from transformers import TrainingArguments\n",
131
+ "\n",
132
+ "trainer = SFTTrainer(\n",
133
+ " model = model, tokenizer = tokenizer, train_dataset = ds,\n",
134
+ " dataset_text_field = 'text', max_seq_length = MAXLEN, packing = False,\n",
135
+ " args = TrainingArguments(\n",
136
+ " per_device_train_batch_size = 2, gradient_accumulation_steps = 4,\n",
137
+ " warmup_steps = 5, num_train_epochs = 3, learning_rate = 2e-4,\n",
138
+ " logging_steps = 5, optim = 'adamw_8bit', weight_decay = 0.01,\n",
139
+ " lr_scheduler_type = 'linear', seed = 42, output_dir = 'outputs',\n",
140
+ " fp16 = not torch.cuda.is_bf16_supported(),\n",
141
+ " bf16 = torch.cuda.is_bf16_supported(),\n",
142
+ " ),\n",
143
+ ")\n",
144
+ "trainer.train()"
145
+ ]
146
+ },
147
+ {
148
+ "cell_type": "markdown",
149
+ "metadata": {},
150
+ "source": ["## 6 · Quick sanity check"]
151
+ },
152
+ {
153
+ "cell_type": "code",
154
+ "execution_count": null,
155
+ "metadata": {},
156
+ "outputs": [],
157
+ "source": [
158
+ "FastLanguageModel.for_inference(model)\n",
159
+ "msgs = [\n",
160
+ " {'role':'system','content': rows[0]['instruction']},\n",
161
+ " {'role':'user','content': rows[0]['input']},\n",
162
+ "]\n",
163
+ "inp = tokenizer.apply_chat_template(msgs, tokenize=True,\n",
164
+ " add_generation_prompt=True, return_tensors='pt').to('cuda')\n",
165
+ "out = model.generate(input_ids=inp, max_new_tokens=160, temperature=0.2)\n",
166
+ "print(tokenizer.decode(out[0][inp.shape[1]:], skip_special_tokens=True))"
167
+ ]
168
+ },
169
+ {
170
+ "cell_type": "markdown",
171
+ "metadata": {},
172
+ "source": ["## 7 · Merge LoRA GGUF (Q8_0) and push to the Hub\n",
173
+ "Unsloth does the llama.cpp conversion for you. Replace `HF_USER`."]
174
+ },
175
+ {
176
+ "cell_type": "code",
177
+ "execution_count": null,
178
+ "metadata": {},
179
+ "outputs": [],
180
+ "source": [
181
+ "from huggingface_hub import login\n",
182
+ "login() # paste a WRITE token\n",
183
+ "\n",
184
+ "HF_USER = 'ranranrunforit'\n",
185
+ "REPO = f'{HF_USER}/chan-compass-qwen3-1.7b-gguf'\n",
186
+ "\n",
187
+ "# saves a Q8_0 GGUF and uploads it (also writes a model card)\n",
188
+ "model.push_to_hub_gguf(REPO, tokenizer, quantization_method='q8_0')\n",
189
+ "print('Done →', f'https://huggingface.co/{REPO}')"
190
+ ]
191
+ },
192
+ {
193
+ "cell_type": "markdown",
194
+ "metadata": {},
195
+ "source": [
196
+ "## 8 · Wire it into the app\n",
197
+ "Note the exact **filename** of the `.gguf` in your new repo (open the repo → Files).\n",
198
+ "In `llm_local.py`, uncomment the `Chan-Tuned` line in `MODEL_ZOO` and set the\n",
199
+ "repo id + filename. Reboot the Space it appears in the Model tab and becomes\n",
200
+ "your published fine-tuned model (🎯 Well-Tuned + 🐜 Tiny Titan)."
201
+ ]
202
+ }
203
+ ],
204
+ "metadata": {
205
+ "accelerator": "GPU",
206
+ "colab": {"provenance": []},
207
+ "kernelspec": {"display_name": "Python 3", "name": "python3"},
208
+ "language_info": {"name": "python"}
209
+ },
210
+ "nbformat": 4,
211
+ "nbformat_minor": 0
212
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
automation.py CHANGED
@@ -1,147 +1,356 @@
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
+ 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 finetune_data
21
+ import llm_local
22
+ import news_watch
23
+ import paths
24
+ import research_agent
25
+ import rotation
26
+ import signal_runner
27
+
28
+ # ─────────────────────────────────────────── Spectrum 2 theme ──
29
+ # Single source of truth: theme.py from the Chan Compass · Spectrum 2 design
30
+ # system (tokens mirrored from the design package's tokens/*.css).
31
+ from theme import THEME as theme, CSS as S2_CSS
32
+
33
+ # ─────────────────────────────────────────── UI callbacks ──
34
+ def ui_run_signals(tickers_text, force):
35
+ """Signals ONLY — pure rule engine, zero LLM, parallel downloads. Fast."""
36
+ import time
37
+ t0 = time.time()
38
+ tickers = [t for t in tickers_text.replace("\n", ",").split(",") if t.strip()]
39
+ df, details, summary, errors = signal_runner.run_signals(tickers or None,
40
+ force=bool(force))
41
+ if errors:
42
+ summary += " · some tickers skipped (data not ready yet — run again)"
43
+ automation.STATE["signals_df"] = df
44
+ automation.STATE["signals_details"] = details
45
+ automation.STATE["signals_summary"] = summary
46
+ choices = sorted(details.keys())
47
+ return (
48
+ df if df is not None else pd.DataFrame(),
49
+ f"{summary} · {time.time()-t0:.1f}s (rule engine only — no LLM in this path)",
50
+ gr.update(choices=choices, value=(choices[0] if choices else None)),
51
+ )
52
+
53
+
54
+ def ui_show_detail(ticker):
55
+ if not ticker:
56
+ return "Select a ticker after running the analysis."
57
+ raw = signal_runner.stock_raw_read(ticker)
58
+ if not raw:
59
+ return "No data for this ticker yet — run the analysis first."
60
+ return f"**Raw read:**\n\n{raw}"
61
+
62
+
63
+ def ui_explain_detail(ticker):
64
+ raw = signal_runner.stock_raw_read(ticker or "")
65
+ if not raw:
66
+ yield "Run the analysis and select a ticker first."
67
+ return
68
+ # Same pattern as Sector Rotation: feed the deterministic ENGLISH raw read
69
+ # to the agent — no Chinese in, no Chinese out, fast (short prompt).
70
+ prompt = ("You are an equity analyst. Based ONLY on this factual read of a US "
71
+ "stock's multi-timeframe Chan-theory verdict, write a short plain-"
72
+ "English summary for a long-term holder: what's the situation today, "
73
+ "should they act or wait, and the key price levels. ≤90 words, no "
74
+ "disclaimers.\n\nRAW READ:\n" + raw)
75
+ yield "🤖 _Translator sub-agent (Qwen3-1.7B · llama.cpp) is summarizing…_"
76
+ final = ""
77
+ for acc in llm_local.chat_stream(prompt, max_tokens=240, temperature=0.2,
78
+ worker="translator"):
79
+ final = acc
80
+ yield "🤖 **AI narrative (Translator sub-agent · Qwen3-1.7B):**\n\n" + acc
81
+ # capture (raw read → narrative) as a fine-tuning pair (🎯 Well-Tuned)
82
  try:
83
+ import finetune_data
84
+ finetune_data.record(raw, final)
85
+ except Exception:
86
+ pass
87
+
88
+
89
+ def ui_refresh_rotation():
90
+ """Tables only — instant. AI narrative is a separate on-demand button."""
91
+ d1, d5, d20, asof = rotation.build_rotation(force=True)
92
+ automation.STATE["rotation"] = (d1, d5, d20, asof)
93
+ raw = rotation.rotation_brief(d1, d5, d20)
94
+ automation.STATE["rotation_narrative"] = raw
95
+ return (rotation.fmt_table(d1), rotation.fmt_table(d5), rotation.fmt_table(d20),
96
+ f"Sector flows as of **{asof}**", f"**Raw read:**\n{raw}")
97
+
98
+
99
+ def ui_rotation_ai():
100
+ d1, d5, d20, asof = automation.STATE["rotation"]
101
+ if d1 is None:
102
+ yield "Refresh the rotation tables first."
103
+ return
104
+ brief = rotation.rotation_brief(d1, d5, d20)
105
+ prompt = ("You are a US equity market strategist. Based only on the sector flow "
106
+ "data below (SPDR ETF proxy: change% × dollar volume, plus RS vs SPY), "
107
+ "write a crisp brief (<150 words): 1) where capital is rotating INTO/OUT "
108
+ "OF; 2) do 1-day moves agree with the 5/20-day trend; 3) one watch item. "
109
+ "No disclaimers.\n\nDATA:\n" + brief[:2200])
110
+ yield ("🤖 _Narrator sub-agent (Qwen3-1.7B · llama.cpp) is reading the flow "
111
+ "tables first words in ~5-15s…_")
112
+ for acc in llm_local.chat_stream(prompt, max_tokens=340, worker="narrator"):
113
+ yield "🤖 **Narrator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
114
+
115
+
116
+ def ui_save_holdings(text):
117
+ saved = news_watch.save_holdings(text.replace("\n", ",").split(","))
118
+ return f"Saved {len(saved)} holding(s): {', '.join(saved) if saved else '—'}"
119
+
120
+
121
+ def ui_check_news():
122
+ last = ""
123
+ for md in news_watch.check_holdings_news_stream():
124
+ last = md
125
+ yield md
126
+ automation.STATE["news_md"] = last
127
+
128
+
129
+ def ui_research(ticker):
130
+ for progress, report in research_agent.run_research_stream(ticker):
131
+ yield progress, report, gr.update()
132
+ yield progress, report, gr.update(choices=research_agent.list_reports())
133
+
134
+
135
+ def ui_open_report(fname):
136
+ return research_agent.read_report(fname)
137
+
 
 
 
 
138
 
139
+ def ui_load_model(name):
140
+ return llm_local.load_model(name, worker="deep")
141
 
142
+
143
+ def ui_automation_panel():
144
+ return automation.schedule_info(), "\n".join(automation.STATE["log"][-30:]) or "(no log yet)"
145
+
146
+
147
+ _SELFTEST = {"done": False, "result": ""}
148
+
149
+
150
+ def _export_dataset():
151
+ path = finetune_data.export()
152
+ if not path:
153
+ return ("No training pairs captured yet. Run the Signals **AI summary** on "
154
+ "a few tickers first — each run is saved automatically.",
155
+ gr.update(visible=False))
156
+ n = finetune_data.count()
157
+ msg = (f"✅ Exported dataset ready below. Captured {n} pair(s). "
158
+ f"Download it, then follow `finetune/FINETUNE_GUIDE.md` to LoRA-tune "
159
+ f"Qwen3-1.7B and publish it.")
160
+ return msg, gr.update(value=path, visible=True)
161
+
162
+
163
+ def _auto_selftest():
164
+ """Runs once, automatically, the moment every sub-agent is loaded — shows a
165
+ self-test verdict without the user clicking anything."""
166
+ if _SELFTEST["done"]:
167
+ return _SELFTEST["result"]
168
+ workers = llm_local.WORKERS
169
+ if not all(w["llm"] is not None for w in workers.values()):
170
+ ready = sum(1 for w in workers.values() if w["llm"] is not None)
171
+ return f"⏳ Loading sub-agents… {ready}/{len(workers)} ready (self-test will run automatically)."
172
+ out = llm_local.quick_test()
173
+ ok = "not loaded" not in out and "error" not in out.lower()
174
+ _SELFTEST["done"] = True
175
+ _SELFTEST["result"] = (("✅ **Every agent is OK now** — self-test passed:\n\n" + out)
176
+ if ok else ("⚠️ Self-test finished with issues:\n\n" + out))
177
+ return _SELFTEST["result"]
178
+
179
+
180
+ # ─────────────────────────────────────────── layout ──
181
+ _GR_MAJOR = int(gr.__version__.split(".")[0])
182
+ _style_kw = {} if _GR_MAJOR >= 6 else {"theme": theme, "css": S2_CSS}
183
+
184
+ with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
185
+ gr.HTML("""
186
+ <div id="s2-hero">
187
+ <div class="mark">🧭</div>
188
+ <div>
189
+ <h1>Chan Compass <span>· US Markets</span></h1>
190
+ <p>Multi-timeframe 缠论 (Chan theory) engine — monthly → 1m nested-interval
191
+ confirmation · sector rotation · local sub-agent pool (llama.cpp).</p>
192
+ </div>
193
+ <div class="chips">
194
+ <span>🧠 Local · no cloud APIs</span><span>🦙 llama.cpp</span>
195
+ <span>🤖 4 sub-agents · 1.7B+4B</span><span>📊 Yahoo Finance</span>
196
+ <span>⏰ 18:10 ET</span><span>💾 /data bucket</span><span>🎨 Spectrum 2</span>
197
+ </div>
198
+ </div>""")
199
+
200
+ with gr.Tab("📈 Signals"):
201
+ with gr.Row():
202
+ tickers_in = gr.Textbox(value=", ".join(signal_runner.DEFAULT_POOL),
203
+ label="Ticker pool (comma separated)", scale=4)
204
+ force_cb = gr.Checkbox(value=False, label="Force fresh download", scale=1)
205
+ run_btn = gr.Button("▶ Run analysis", variant="primary", scale=1)
206
+ sig_summary = gr.Markdown(automation.STATE["signals_summary"])
207
+ sig_table = gr.Dataframe(label="Tomorrow's plan — long-hold mode (sorted: BUY → SELL → HOLD → WAIT)",
208
+ interactive=False, wrap=True)
209
+ gr.Markdown("**Stock summary** — pick a ticker for a plain-English raw read, "
210
+ "then let the Translator sub-agent write an AI narrative.",
211
+ elem_classes=["s2-footnote"])
212
+ with gr.Row():
213
+ detail_pick = gr.Dropdown(choices=[], label="Ticker", scale=2)
214
+ explain_btn = gr.Button("🤖 AI summary (local LLM)", scale=1)
215
+ detail_box = gr.Markdown(label="Raw read")
216
+ explain_box = gr.Markdown(elem_classes=["ai-panel"])
217
+
218
+ with gr.Tab("🔄 Sector Rotation"):
219
+ gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500 coverage). "
220
+ "**Flow proxy = price change % × dollar volume**; RS = return minus SPY. "
221
+ "True per-sector fund-flow feeds are paid data — this is the standard free proxy.")
222
+ with gr.Row():
223
+ rot_btn = gr.Button("↻ Refresh rotation (instant)", variant="primary")
224
+ rot_ai_btn = gr.Button("🤖 AI narrative (local LLM)")
225
+ rot_asof = gr.Markdown("Press refresh (or Run analysis on the Signals tab).")
226
+ with gr.Row():
227
+ rot_1d = gr.Dataframe(label="1-Day (today's rotation)", interactive=False)
228
+ with gr.Row():
229
+ rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
230
+ rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
231
+ rot_ai = gr.Markdown(label="AI rotation narrative", elem_classes=["ai-panel"])
232
+
233
+ with gr.Tab("📰 Watchlist News"):
234
+ gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
235
+ "News found → AI brief is pushed below. No news → the ticker is ignored "
236
+ "(listed under *Quiet today*).")
237
+ with gr.Row():
238
+ hold_in = gr.Textbox(value=", ".join(news_watch.load_holdings()),
239
+ label="My holdings (comma separated)", scale=3)
240
+ save_btn = gr.Button("💾 Save holdings", scale=1)
241
+ news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
242
+ hold_status = gr.Markdown()
243
+ news_out = gr.Markdown(elem_classes=["ai-panel"])
244
+
245
+ with gr.Tab("🧪 Auto Research"):
246
+ gr.Markdown("**Multi-step research agent** (fully local): PLAN → 5 evidence tools "
247
+ "(fundamentals · quarterly financials · price action · **the Chan engine "
248
+ "itself** · news) → section-by-section analysis → report. Every step is "
249
+ "logged to a JSON **agent trace** on persistent storage. "
250
+ "New tickers entering the signal pool get a report **auto-generated** "
251
+ "by the daily pipeline.")
252
+ with gr.Row():
253
+ res_in = gr.Textbox(label="Ticker", placeholder="e.g. NVDA", scale=3)
254
+ res_btn = gr.Button("🤖 Run research agent", variant="primary", scale=1)
255
+ res_progress = gr.Markdown()
256
+ res_out = gr.Markdown(elem_classes=["ai-panel"])
257
+ gr.Markdown("**Report library** (auto + manual, stored on `/data`):",
258
+ elem_classes=["s2-footnote"])
259
+ with gr.Row():
260
+ rep_pick = gr.Dropdown(choices=research_agent.list_reports(),
261
+ label="Saved reports", scale=3)
262
+ rep_open = gr.Button("📂 Open report", scale=1)
263
+ rep_view = gr.Markdown(elem_classes=["ai-panel"])
264
+
265
+ with gr.Tab("⏰ Automation"):
266
+ gr.Markdown(paths.storage_status())
267
+ auto_md = gr.Markdown(automation.schedule_info())
268
+ with gr.Row():
269
+ auto_now = gr.Button("⚡ Run now", variant="primary")
270
+ auto_msg = gr.Markdown()
271
+ auto_log = gr.Textbox(lines=14, label="Pipeline log (live — updates every 2s)",
272
+ elem_id="detail-log")
273
+ traces_md = gr.Markdown(research_agent.list_traces())
274
+ auto_log_timer = gr.Timer(2.0)
275
+ auto_log_timer.tick(lambda: "\n".join(automation.STATE["log"][-40:])
276
+ or "(no log yet)", None, auto_log)
277
+
278
+ with gr.Tab("🧠 Model"):
279
+ gr.Markdown("All AI runs **locally** through **llama.cpp** (llama-cpp-python) with "
280
+ "Qwen3 GGUF weights — every option is far below the 32B-parameter cap, "
281
+ "and nothing leaves the machine. **First load installs the llama.cpp "
282
+ "runtime + downloads the GGUF (one-time, usually 1–3 min; worst case "
283
+ "~15 min if it has to compile).** Signals/rotation/news never depend on it.")
284
+ gr.Markdown("**Sub-agent pool:** `fast` Translator/Narrator (Qwen3-1.7B, "
285
+ "fixed) handles Explain / rotation narrative / news briefs; "
286
+ "`deep` Analyst writes research reports. Each has its own lock — "
287
+ "they run in parallel. Pick the Analyst model below:")
288
+ model_pick = gr.Radio(choices=list(llm_local.MODEL_ZOO.keys()),
289
+ value=llm_local.DEFAULT_MODEL, label="Analyst (deep) model")
290
+ with gr.Row():
291
+ load_btn = gr.Button("⬇ Load model", variant="primary")
292
+ test_btn = gr.Button("⚡ Test sub-agents now")
293
+ model_status = gr.Markdown(llm_local.status())
294
+ model_test_out = gr.Markdown()
295
+ model_timer = gr.Timer(2.0)
296
+ model_timer.tick(lambda: llm_local.status(), None, model_status)
297
+ autotest_timer = gr.Timer(3.0)
298
+ autotest_timer.tick(_auto_selftest, None, model_test_out)
299
+
300
+ gr.Markdown("---\n### 🎯 Fine-tuning dataset (Well-Tuned badge)\n"
301
+ "Every Signals **AI summary** you run is saved as a "
302
+ "(raw read → narrative) training pair on `/data`. Capture a "
303
+ "few hundred, export the JSONL, then follow `FINETUNE_GUIDE.md` "
304
+ "to LoRA-tune Qwen3-1.7B and publish it.")
305
+ ft_status = gr.Markdown(finetune_data.status_line())
306
+ with gr.Row():
307
+ ft_refresh = gr.Button("↻ Count pairs")
308
+ ft_export = gr.Button("⬇ Export dataset (JSONL)", variant="primary")
309
+ ft_out = gr.Markdown()
310
+ ft_file = gr.File(label="Download training data", visible=False)
311
+ ft_refresh.click(lambda: finetune_data.status_line(), None, ft_status)
312
+ ft_export.click(_export_dataset, None, [ft_out, ft_file])
313
+
314
+ gr.Markdown("Chan Compass · educational tool, not investment advice · "
315
+ "data: Yahoo Finance · design language: Adobe Spectrum 2",
316
+ elem_classes=["s2-footnote"])
317
+
318
+ # wiring
319
+ run_btn.click(ui_run_signals, [tickers_in, force_cb],
320
+ [sig_table, sig_summary, detail_pick])
321
+ detail_pick.change(ui_show_detail, detail_pick, detail_box)
322
+ explain_btn.click(ui_explain_detail, detail_pick, explain_box)
323
+ rot_btn.click(ui_refresh_rotation, None, [rot_1d, rot_5d, rot_20d, rot_asof, rot_ai])
324
+ rot_ai_btn.click(ui_rotation_ai, None, rot_ai)
325
+ save_btn.click(ui_save_holdings, hold_in, hold_status)
326
+ news_btn.click(ui_check_news, None, news_out)
327
+ res_btn.click(ui_research, res_in, [res_progress, res_out, rep_pick])
328
+ rep_open.click(ui_open_report, rep_pick, rep_view)
329
+ auto_now.click(lambda: automation.run_pipeline(force=True), None, auto_msg)
330
+ load_btn.click(ui_load_model, model_pick, model_status)
331
+ test_btn.click(lambda: llm_local.quick_test(), None, model_test_out)
332
+
333
+ automation.start_scheduler()
334
+
335
+ # Auto-load the default local model in the background so AI features (English
336
+ # explanations, rotation narrative, research agent) are ready without a click.
337
+ def _auto_load_model():
338
  try:
339
+ automation._log("Auto-loading sub-agents (llama.cpp)…")
340
+ llm_local.auto_load_all()
341
  except Exception as e:
342
+ automation._log(f"Sub-agent auto-load failed: {e}")
343
+
344
+ try:
345
+ import paths as _paths
346
+ automation._log(_paths.storage_status())
347
+ except Exception:
348
+ pass
349
+ if os.environ.get("AUTO_LOAD_MODEL", "1") == "1":
350
+ threading.Thread(target=_auto_load_model, daemon=True).start()
351
+
352
+ if __name__ == "__main__":
353
+ if _GR_MAJOR >= 6:
354
+ demo.launch(theme=theme, css=S2_CSS)
355
+ else:
356
+ demo.launch()
 
 
 
 
 
chan_engine.py CHANGED
@@ -1,1451 +1,147 @@
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
+ 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_enhance.py CHANGED
@@ -1,123 +1,1451 @@
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
+ 缠论引擎 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_finetune_colab.ipynb ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🎯 Well-Tuned — fine-tune & publish your own model (step by step)
2
+
3
+ Goal: publish a small **fine-tuned** model on Hugging Face and use it inside the
4
+ app. We fine-tune **Qwen3-1.7B** on one focused skill the app already performs —
5
+ turning a Chan-theory *raw read* into a crisp long-hold *AI summary* — then plug
6
+ it back in as the Translator sub-agent. This earns **🎯 Well-Tuned** and
7
+ strengthens **🐜 Tiny Titan** (a 1.7B model doing the job well).
8
+
9
+ Everything you need is already in the app and in this `finetune/` folder. Total
10
+ hands-on time ≈ 1 hour, most of it waiting.
11
+
12
+ ---
13
+
14
+ ## Why this approach (read once)
15
+
16
+ - **Self-sourcing data.** You don't hunt for a dataset — the app *generates* it.
17
+ Each time you run the Signals **AI summary**, the (raw read → narrative) pair
18
+ is saved to `/data/dataset/pairs.jsonl`. A few hundred pairs is plenty.
19
+ - **Single skill, small model.** Narrowing the task lets 1.7B match or beat a
20
+ generic 4B, which is the whole point of the hackathon's "think small".
21
+ - **Honest fit.** The fine-tune does exactly what the app uses it for — judges
22
+ can see the loop close.
23
+
24
+ ---
25
+
26
+ ## Step 1 — Capture training data (in the app, ~15 min)
27
+
28
+ 1. Open the Space → **Signals** → **Run analysis**.
29
+ 2. For each ticker: pick it in the dropdown, click **🤖 AI summary**. Each run
30
+ auto-saves one training pair (you'll see them counted on the Model tab).
31
+ 3. Vary it: change the ticker pool (large caps, a few volatile names, an ETF),
32
+ re-run analysis, summarize again. Aim for **150–500 pairs**.
33
+ - Tip: the **Auto Research** reports and the **rotation narrative** also
34
+ exercise the models, but only the Signals AI summary is captured for SFT
35
+ (it's the cleanest single-skill pair).
36
+ 4. **Model tab → 🎯 Fine-tuning dataset → ⬇ Export dataset (JSONL)**. It writes
37
+ `/data/dataset/chan_sft_<timestamp>.jsonl` and tells you the pair count.
38
+
39
+ ### Get the file onto your computer
40
+ The file lives on the Space's `/data` bucket. Easiest way to grab it:
41
+ ```bash
42
+ pip install huggingface_hub
43
+ huggingface-cli login # your token
44
+ # list what's in the Space's persistent storage isn't exposed directly, so
45
+ # instead download via the Space's file browser, OR re-export to the repo:
46
+ ```
47
+ Simplest reliable path: in the **Model tab** the export prints the full path;
48
+ open the Space's **Files** isn't enough for /data, so use the Space terminal if
49
+ you enabled "Dev mode", **or** temporarily add a `gr.File` download — if you
50
+ want that button, tell me and I'll wire it in. Otherwise the Colab notebook
51
+ also accepts the JSONL via direct upload (Step 2 there).
52
+
53
+ > If you'd rather not fish the file out of `/data`, I can add a one-click
54
+ > **"Download dataset"** button to the Model tab — say the word.
55
+
56
+ ---
57
+
58
+ ## Step 2 — Train on a free Colab T4 (~25–40 min)
59
+
60
+ 1. Go to <https://colab.research.google.com> → **File → Upload notebook** →
61
+ pick `finetune/chan_finetune_colab.ipynb` from this project.
62
+ 2. **Runtime → Change runtime type → T4 GPU → Save.**
63
+ 3. Run the cells top to bottom:
64
+ - Cell 1 installs Unsloth (fast, low-memory LoRA).
65
+ - Cell 2 asks you to **upload your `chan_sft_*.jsonl`**.
66
+ - Cells 3–5 load Qwen3-1.7B in 4-bit, attach LoRA adapters, and train
67
+ (3 epochs; 10–20 min for a few hundred rows).
68
+ - Cell 6 prints a sample generation so you can eyeball quality.
69
+ 4. If the sample looks off, raise `num_train_epochs` to 4–5 and re-run Cell 5.
70
+
71
+ ---
72
+
73
+ ## Step 3 — Convert to GGUF + publish (~5 min, same notebook)
74
+
75
+ 1. Cell 7: `login()` — paste a **write** token (HF → Settings → Access Tokens).
76
+ 2. Set `HF_USER = 'ranranrunforit'`. The cell calls
77
+ `model.push_to_hub_gguf(REPO, tokenizer, quantization_method='q8_0')`, which
78
+ merges the LoRA, converts to GGUF via llama.cpp, and uploads to
79
+ `ranranrunforit/chan-compass-qwen3-1.7b-gguf`.
80
+ 3. Open the new repo → **Files** → note the exact `.gguf` filename (e.g.
81
+ `chan-compass-qwen3-1.7b-gguf.Q8_0.gguf` — Unsloth's name may differ).
82
+
83
+ ---
84
+
85
+ ## Step 4 — Wire it into the app (1 line, ~2 min)
86
+
87
+ In `llm_local.py`, find the commented block in `MODEL_ZOO` and edit it to match
88
+ your repo id and the **exact** filename from Step 3:
89
+
90
+ ```python
91
+ "Chan-Tuned Qwen3-1.7B · my fine-tune": (
92
+ "ranranrunforit/chan-compass-qwen3-1.7b-gguf",
93
+ "chan-compass-qwen3-1.7b-gguf.Q8_0.gguf"), # ← exact filename
94
+ ```
95
+
96
+ Optionally make it the fast sub-agent's default so the app uses it everywhere:
97
+
98
+ ```python
99
+ FAST_MODEL = "Chan-Tuned Qwen3-1.7B · my fine-tune"
100
+ ```
101
+
102
+ Commit, reboot the Space. The Model tab now lists your fine-tune, and it loads
103
+ through llama.cpp like any other GGUF. **🎯 Well-Tuned achieved.**
104
+
105
+ ---
106
+
107
+ ## Checklist for the badge
108
+
109
+ - [ ] Dataset exported from the app (`chan_sft_*.jsonl`).
110
+ - [ ] LoRA trained on Qwen3-1.7B (Colab T4).
111
+ - [ ] Merged + converted to GGUF, **published to a public HF repo**.
112
+ - [ ] App's `MODEL_ZOO` points at that repo; it loads and answers.
113
+ - [ ] (Nice) Mention the fine-tune in your demo + blog (`BLOG_DRAFT.md`).
114
+
115
+ ## Troubleshooting
116
+
117
+ - **Colab OOM on T4:** keep `load_in_4bit=True`, batch size 2, max_seq_length
118
+ 2048 (defaults here). Don't bump LoRA `r` above 32.
119
+ - **`push_to_hub_gguf` slow/fails:** re-run the cell; conversion downloads
120
+ llama.cpp once. If it still fails, save the merged model with
121
+ `model.save_pretrained_merged(...)` and convert locally with
122
+ `llama.cpp/convert_hf_to_gguf.py` (also documented in Unsloth's README).
123
+ - **Model loads but talks oddly:** too few pairs or too many epochs. 200+ pairs,
124
+ 3 epochs is the sweet spot. Keep temperature low (the app uses 0.2 here).
125
+ - **Space can't find the file:** the filename in `MODEL_ZOO` must match the repo
126
+ exactly (case-sensitive), and the repo must be public (or set HF_TOKEN).
chan_glue.py CHANGED
@@ -1,70 +1,123 @@
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
+ chan_enhance.py — 缠论系统增强(查漏补缺) · 纯函数版(非Hook)
 
 
 
 
 
 
 
 
 
3
  """
4
  from __future__ import annotations
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
+ W_REVERSAL = 1.0
8
+ W_TRAP = 0.85
9
+ W_RELAY = 0.6
10
+ W_UNKNOWN = 0.7
11
+
12
+ # 第16课 六种基本走势 → 买法分类的中文名(配合英文 method 一起展示)
13
+ METHOD_CN = {
14
+ 'reversal': '反转式(下跌+盘整+上涨 / 趋势底背驰一买, 力度最强, 权重最高)',
15
+ 'trap': '陷阱式(下跌+上涨, 空头陷阱式二买, 中等力度)',
16
+ 'relay': '中继式(上涨+盘整+上涨, 三买, 趋势中继)',
17
+ 'unknown': '未分类',
18
+ }
19
+
20
+ WEAK_ARM_GAIN = 0.04
21
+ WEAK_GIVEBACK = 0.22
22
+
23
+ L92_EXIT_ENABLE = False
24
+ L92_EXIT_FLOATING_MAX = 0.015
25
+ L92_ZD_BREAK_BUF = 0.0
26
+
27
+
28
+ def classify_buy_method(sig_kind, ml) -> str:
29
+ sig = ml.daily.signal if (ml and ml.daily) else None
30
+ dg = getattr(sig, 'diverge_grade', None) if sig is not None else None
31
+ if sig_kind == 'B1':
32
+ return 'reversal'
33
+ if sig_kind == 'B3':
34
+ return 'relay'
35
+ if sig_kind == 'B2':
36
+ # L37 背驰分辨: 只有"趋势背驰"(≥2个同向中枢后的背驰)才是标准的趋势转折,
37
+ # 对应第16课"下跌+盘整+上涨"反转式买法; 盘整背驰力度弱, 仍按陷阱式处理。
38
+ if (dg is not None and getattr(dg, 'grade', '') == 'STRONG'
39
+ and getattr(dg, 'is_trend_divergence', False)):
40
+ return 'reversal'
41
+ return 'trap'
42
+ return 'unknown'
43
+
44
+
45
+ def l37_divergence_note(ml) -> str:
46
+ """L37 背驰分辨(区分背驰段构成): 趋势背驰=至少两个同向中枢后的背驰, 转折级别高;
47
+ 盘整背驰=单中枢内的力度衰竭, 只保证回拉中枢, 不保证反转。"""
48
+ sig = ml.daily.signal if (ml and ml.daily) else None
49
+ dg = getattr(sig, 'diverge_grade', None) if sig is not None else None
50
+ if dg is None or getattr(dg, 'grade', 'NONE') == 'NONE':
51
+ return ''
52
+ if getattr(dg, 'is_trend_divergence', False):
53
+ return (f'第37课: 趋势背驰({getattr(dg, "n_trend_pivots", "?")}个同向中枢), '
54
+ f'转折至少回拉至最后一个中枢, 大概率反转 → 可按反转式买法重仓')
55
+ return ('第37课: 盘整背驰(背驰段内仅1个中枢), 只保证回拉中枢一次, '
56
+ '不保证趋势反转 → 轻仓短打, 回拉到中枢即考虑兑现')
57
+
58
+
59
+ def buy_method_weight(sig_kind, ml):
60
+ method = classify_buy_method(sig_kind, ml)
61
+ wmap = {'reversal': W_REVERSAL, 'trap': W_TRAP, 'relay': W_RELAY, 'unknown': W_UNKNOWN}
62
+ return method, wmap.get(method, W_UNKNOWN)
63
+
64
+
65
+ def recompute_evo(ml) -> str:
66
+ sig = ml.daily.signal if (ml and ml.daily) else None
67
+ if sig is not None:
68
+ evo = (sig.extras or {}).get('post_evolution', '')
69
+ if evo:
70
+ return evo
71
+ dv = ml.daily if ml else None
72
+ if dv is not None and dv.zd is not None and dv.zg is not None:
73
+ if ml.cur_price < dv.zd:
74
+ return 'case1_extend'
75
+ return ''
76
+
77
+
78
+ def weak_evo_giveback(pos, ml, default_giveback):
79
+ evo = recompute_evo(ml) if ml is not None else (pos.get('post_evo') or '')
80
+ if evo == 'case1_extend':
81
+ return WEAK_ARM_GAIN, WEAK_GIVEBACK
82
+ return None, default_giveback
83
+
84
+
85
+ def l92_should_exit(pos, ml, close_p, floating):
86
+ if not L92_EXIT_ENABLE or ml is None or ml.daily is None:
87
+ return None
88
+ if floating > L92_EXIT_FLOATING_MAX:
89
+ return None
90
+ zd = ml.daily.zd
91
+ if zd is None:
92
+ return None
93
+ if close_p < zd * (1 - L92_ZD_BREAK_BUF) and close_p > pos.get('stop_px', 0):
94
+ return ('L92_EXIT',
95
+ f'第92课中枢震荡监视器: 现价{close_p:.3f}已破日线中枢下沿ZD{zd:.3f}'
96
+ f'(向下变盘) 且浮盈仅{floating:+.1%} → 提前减仓, 不等磨到结构止损')
97
+ return None
98
+
99
+
100
+ def predict_enhance(ml) -> dict:
101
+ out = {}
102
+ if ml is None or ml.daily is None:
103
+ return out
104
+ sig_kind = ml.final_kind or ''
105
+ if sig_kind in ('B1', 'B2', 'B3'):
106
+ method, weight = buy_method_weight(sig_kind, ml)
107
+ out['buy_method'] = method
108
+ out['buy_method_cn'] = METHOD_CN.get(method, method)
109
+ out['suggest_weight'] = weight
110
+ out['l16_note'] = f'第16课: 买法分类={method}〔{METHOD_CN.get(method, method)}〕, 建议仓位权重{weight:.2f}'
111
+ l37 = l37_divergence_note(ml)
112
+ if l37:
113
+ out['l37_note'] = l37
114
+ evo = recompute_evo(ml)
115
+ if evo:
116
+ out['post_evolution'] = evo
117
+ out['evo_hint'] = ('第29课: 最弱形态(反弹/回落未回中枢), 持有宜收紧移动止盈, 尽快兑现'
118
+ if evo == 'case1_extend'
119
+ else '第29课: 形态较强(回到中枢), 可持有等三买/趋势延续')
120
+ zd = ml.daily.zd
121
+ if zd is not None and ml.cur_price < zd:
122
+ out['l92_warn'] = f'第92课: 现价{ml.cur_price:.3f}已在日线中枢下沿ZD{zd:.3f}下方 → 向下变盘预警'
123
+ return out
chan_multilevel.py CHANGED
@@ -1,884 +1,70 @@
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_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()
data_us.py CHANGED
@@ -1,147 +1,884 @@
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
+
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 ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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])
llm_local.py CHANGED
@@ -1,306 +1,104 @@
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 · Translator/Narrator Qwen3-1.7B Q8 (≈2 GB RAM)
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
- "Qwen3-1.7B · Tiny Titan (≤4B award class)": (
29
- "Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf"),
30
- "Qwen3-4B · default — fast + smart, still ≤4B": (
31
- "Qwen/Qwen3-4B-GGUF", "Qwen3-4B-Q4_K_M.gguf"),
32
- "Qwen3-8B · best balance on 8 vCPU / 32 GB": (
33
- "Qwen/Qwen3-8B-GGUF", "Qwen3-8B-Q4_K_M.gguf"),
34
- "Qwen3-14B · max quality (still far under 32B cap)": (
35
- "Qwen/Qwen3-14B-GGUF", "Qwen3-14B-Q4_K_M.gguf"),
36
- }
37
- FAST_MODEL = "Qwen3-1.7B · Tiny Titan (≤4B award class)"
38
- DEFAULT_MODEL = "Qwen3-4B · default — fast + smart, still ≤4B"
39
-
40
- _THINK_RE = re.compile(r"<think>.*?</think>", re.S)
41
- _NCPU = max(2, (os.cpu_count() or 4))
42
-
43
- # One dedicated sub-agent per feature — independent locks, so Signals-Explain,
44
- # Rotation narrative, News briefs and Auto-Research never fight over a model.
45
- # Three tiny 1.7B instances share ONE GGUF file on disk (~2 GB RAM each) and
46
- # the 4B Analyst writes reports. Total ≈ 9 GB on a 32 GB Space.
47
- WORKER_LABEL = {
48
- "translator": "Translator sub-agent (Signals · Explain)",
49
- "narrator": "Narrator sub-agent (Sector Rotation)",
50
- "reporter": "Reporter sub-agent (News · Research support)",
51
- "analyst": "Analyst sub-agent (Auto Research)",
52
- }
53
-
54
-
55
- def _mk(model):
56
- return {"model": model, "llm": None, "lock": threading.Lock(),
57
- "load_lock": threading.Lock(), "stage": "idle", "detail": "", "ts": None}
58
-
59
 
60
- WORKERS = {
61
- "translator": _mk(FAST_MODEL),
62
- "narrator": _mk(FAST_MODEL),
63
- "reporter": _mk(FAST_MODEL),
64
- "analyst": _mk(DEFAULT_MODEL),
65
- }
66
- # legacy aliases
67
- _ALIAS = {"fast": "translator", "deep": "analyst"}
68
 
 
 
 
 
 
69
 
70
- def _wk(worker: str) -> str:
71
- return _ALIAS.get(worker, worker)
72
 
73
- _install_lock = threading.Lock()
 
 
 
 
74
 
75
 
76
- def _set_stage(worker: str, stage: str, detail: str = ""):
77
- import datetime as _dt
78
- w = WORKERS[worker]
79
- w.update(stage=stage, detail=detail[:400],
80
- ts=_dt.datetime.utcnow().strftime("%H:%M:%S UTC"))
81
- try:
82
- import automation
83
- automation._log(f"[{WORKER_LABEL[worker]}] {stage}: {detail[:140]}")
84
- except Exception:
85
- pass
86
-
87
-
88
- # ─────────────────────── runtime install (once, persisted) ───────────────────────
89
- # Installed at RUNTIME, not at Space build time: the HF build container has
90
- # little RAM and gets OOM-killed compiling the C++ extension; the runtime
91
- # container has the real hardware. Prebuilt CPU wheel first, capped-parallelism
92
- # source build as fallback. Persisted to /data/pylibs.
93
- _WHEEL_INDEX = "https://abetlen.github.io/llama-cpp-python/whl/cpu"
94
- _LLAMA_REQ = "llama-cpp-python>=0.3.8" # >=0.3.8 → Qwen3 architecture support
95
-
96
-
97
- def _ensure_llama_cpp(worker: str) -> str:
98
  try:
99
- import llama_cpp # noqa: F401
100
- return ""
101
- except ImportError:
102
  pass
103
- import subprocess
104
- import sys
105
- with _install_lock:
106
- try: # another thread may have finished it while we waited
107
- import llama_cpp # noqa: F401
108
- return ""
109
- except ImportError:
110
- pass
111
- env = dict(os.environ)
112
- env["CMAKE_BUILD_PARALLEL_LEVEL"] = "4"
113
- _set_stage(worker, "installing llama.cpp runtime",
114
- "trying official prebuilt CPU wheel (≈1 min)…")
115
- if paths.PERSISTENT:
116
- base = [sys.executable, "-m", "pip", "install", "--prefer-binary",
117
- "--target", paths.PYLIBS_DIR]
118
- else:
119
- base = [sys.executable, "-m", "pip", "install", "--user", "--prefer-binary"]
120
- r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX,
121
- "--only-binary", "llama-cpp-python", _LLAMA_REQ],
122
- capture_output=True, text=True, env=env, timeout=600)
123
- if r.returncode != 0:
124
- _set_stage(worker, "installing llama.cpp runtime",
125
- "no prebuilt wheel matched — compiling from source "
126
- "(one-time ~10-15 min; other tabs keep working)…")
127
- r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX, _LLAMA_REQ],
128
- capture_output=True, text=True, env=env, timeout=2400)
129
- if r.returncode != 0:
130
- err = (r.stderr or r.stdout or "")[-800:]
131
- _set_stage(worker, "install FAILED", err)
132
- return "Could not install llama-cpp-python at runtime:\n" + err
133
- import importlib
134
- import site
135
- cands = [paths.PYLIBS_DIR] if paths.PERSISTENT else []
136
- usp = site.getusersitepackages()
137
- cands += usp if isinstance(usp, list) else [usp]
138
- for p in cands:
139
- if p and p not in sys.path:
140
- sys.path.append(p)
141
- importlib.invalidate_caches()
142
- try:
143
- import llama_cpp # noqa: F401
144
- return ""
145
- except Exception as e:
146
- _set_stage(worker, "install FAILED", f"installed but import failed: {e}")
147
- return f"Installed but import failed: {e}"
148
 
149
 
150
- # ─────────────────────── loading ───────────────────────
151
- def load_model(name: str, worker: str = "analyst") -> str:
152
- worker = _wk(worker)
153
- """Load a GGUF into a worker slot. Non-blocking: if that worker is already
154
- installing/loading, returns its live stage instead of hanging the click."""
155
- w = WORKERS[worker]
156
- if not w["load_lock"].acquire(timeout=2):
157
- return (f"⏳ {WORKER_LABEL[worker]} is busy — current stage: "
158
- f"**{w['stage']}** ({w['detail'] or '…'}). Press “↻ Refresh status”.")
159
  try:
160
- if w["llm"] is not None and w["model"] == name:
161
- return f"Already loaded on {WORKER_LABEL[worker]}: {name}"
162
- err = _ensure_llama_cpp(worker)
163
- if err:
164
- return err
165
- try:
166
- from llama_cpp import Llama
167
- except Exception as e:
168
- _set_stage(worker, "import FAILED", str(e))
169
- return f"llama-cpp-python is not available: {e}"
170
- repo, fname = MODEL_ZOO[name]
171
- try:
172
- _set_stage(worker, "downloading GGUF",
173
- f"{repo}/{fname} (cached on /data after first time)")
174
- path = hf_hub_download(repo_id=repo, filename=fname)
175
- except Exception as e:
176
- _set_stage(worker, "download FAILED", str(e))
177
- return f"Could not download {repo}/{fname}: {e}"
178
- try:
179
- _set_stage(worker, "loading model into RAM", name)
180
- w["llm"] = None
181
- small = worker != "analyst"
182
- w["llm"] = Llama(
183
- model_path=path,
184
- n_ctx=4096 if small else 6144,
185
- n_threads=(4 if small else _NCPU), # leave headroom for parallel agents
186
- n_threads_batch=(6 if small else _NCPU),
187
- n_batch=512,
188
- verbose=False,
189
- )
190
- w["model"] = name
191
- _set_stage(worker, "ready", name)
192
- return f"✅ {WORKER_LABEL[worker]} ready: {name}"
193
- except Exception as e:
194
- w["llm"] = None
195
- _set_stage(worker, "load FAILED", str(e))
196
- return f"Failed to load model: {e}"
197
- finally:
198
- w["load_lock"].release()
199
 
200
 
201
- def auto_load_all():
202
- """Startup: tiny agents first (one small GGUF download serves all three),
203
- then the Analyst. Runs in a background thread."""
204
- for key in ("translator", "narrator", "reporter", "analyst"):
205
- load_model(WORKERS[key]["model"], worker=key)
206
-
207
-
208
- # ─────────────────────── status ───────────────────────
209
- def is_loaded(worker: str = None) -> bool:
210
- if worker:
211
- return WORKERS[_wk(worker)]["llm"] is not None
212
- return any(w["llm"] is not None for w in WORKERS.values())
213
-
214
-
215
- def available() -> bool:
216
- return is_loaded()
217
-
218
-
219
- def status() -> str:
220
- lines = []
221
- for key in ("translator", "narrator", "reporter", "analyst"):
222
- w = WORKERS[key]
223
- label = WORKER_LABEL[key]
224
- if w["llm"] is not None:
225
- lines.append(f"✅ **{label}** — {w['model']} · llama.cpp, local")
226
- elif w["stage"] == "idle":
227
- lines.append(f"⚪ **{label}** — not loaded yet (auto-loads at startup)")
228
- else:
229
- lines.append(f"⏳ **{label}** — {w['stage']} ({w['ts']}): {w['detail'] or '…'}")
230
- lines.append("\n_Each sub-agent has its own lock — Explain / narrative / "
231
- "research run in parallel without “model busy”._")
232
- return "\n\n".join(lines)
233
-
234
-
235
- # ─────────────────────── inference ───────────────────────
236
- DEFAULT_SYSTEM = ("You are a sub-agent of Chan Compass, a US-equity dashboard. "
237
- "Answer in clear, concise English.")
238
- MAX_PROMPT_CHARS = 3200
239
-
240
-
241
- def _messages(user: str, system: str):
242
- return [{"role": "system", "content": system + " /no_think"},
243
- {"role": "user", "content": user[:MAX_PROMPT_CHARS]}]
244
-
245
-
246
- def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
247
- system: str = DEFAULT_SYSTEM, worker: str = "translator") -> str:
248
- """Blocking chat on one sub-agent (used by pipeline/agent code)."""
249
- w = WORKERS[_wk(worker)]; worker = _wk(worker)
250
- if w["llm"] is None:
251
  return ""
252
- if not w["lock"].acquire(timeout=180):
253
- return f"({WORKER_LABEL[worker]} busy — try again in a moment)"
254
  try:
255
- out = w["llm"].create_chat_completion(
256
- messages=_messages(user, system),
257
- max_tokens=max_tokens, temperature=temperature)
258
- txt = out["choices"][0]["message"]["content"] or ""
259
- return _THINK_RE.sub("", txt).strip()
260
- except Exception as e:
261
- return f"(model error: {e})"
262
- finally:
263
- w["lock"].release()
264
-
265
-
266
- def chat_stream(user: str, max_tokens: int = 500, temperature: float = 0.3,
267
- system: str = DEFAULT_SYSTEM, worker: str = "translator"):
268
- """Streaming chat on one sub-agent — yields cumulative text immediately."""
269
- w = WORKERS[_wk(worker)]; worker = _wk(worker)
270
- if w["llm"] is None:
271
- yield (f"⏳ {WORKER_LABEL[worker]} isn't ready yet — "
272
- f"stage: {w['stage']}. Check the **Model** tab.")
273
- return
274
- if not w["lock"].acquire(timeout=5):
275
- yield f"⏳ {WORKER_LABEL[worker]} is finishing another answer — try again in a few seconds."
276
- return
277
  try:
278
- acc = ""
279
- for chunk in w["llm"].create_chat_completion(
280
- messages=_messages(user, system),
281
- max_tokens=max_tokens, temperature=temperature, stream=True):
282
- delta = chunk["choices"][0]["delta"].get("content") or ""
283
- if not delta:
284
- continue
285
- acc += delta
286
- yield _THINK_RE.sub("", acc).replace("<think>", "").strip()
287
- if not acc.strip():
288
- yield "(model returned no text — try again)"
289
- except Exception as e:
290
- yield f"(model error: {e})"
291
- finally:
292
- w["lock"].release()
293
 
294
 
295
- def quick_test() -> str:
296
- """Sanity check both sub-agents."""
297
- import time
298
- outs = []
299
- for key in ("translator", "narrator", "reporter", "analyst"):
300
- if WORKERS[key]["llm"] is None:
301
- outs.append(f"{WORKER_LABEL[key]}: not loaded ({WORKERS[key]['stage']})")
302
- continue
303
- t0 = time.time()
304
- out = chat("Reply with exactly: OK", max_tokens=6, temperature=0.0, worker=key)
305
- outs.append(f"{WORKER_LABEL[key]}: **{out or '(no output)'}** · {time.time()-t0:.1f}s")
306
- return "\n\n".join(outs)
 
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 the UI prefix and any stray think tags
37
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
38
+ text = text.replace("🤖 **AI narrative (Translator sub-agent · Qwen3-1.7B):**", "")
39
+ return text.strip()
40
 
41
 
42
+ def record(raw_read: str, narrative: str):
43
+ """Append one training pair. Silently ignores junk / unloaded-model output."""
44
+ narrative = _clean(narrative)
45
+ if not raw_read or not narrative or len(narrative) < 40:
46
+ return
47
+ if narrative.startswith(("⏳", "(", "Run the analysis")):
48
+ return
49
+ row = {"instruction": INSTRUCTION, "input": raw_read.strip(), "output": narrative}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  try:
51
+ with _lock, open(_PAIRS, "a", encoding="utf-8") as f:
52
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
53
+ except OSError:
54
  pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
 
56
 
57
+ def count() -> int:
 
 
 
 
 
 
 
 
58
  try:
59
+ with open(_PAIRS, encoding="utf-8") as f:
60
+ return sum(1 for _ in f)
61
+ except OSError:
62
+ return 0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
 
65
+ def export() -> str:
66
+ """De-duplicate and write a timestamped JSONL. Returns the file path so the
67
+ UI can offer it as a direct download."""
68
+ n = count()
69
+ if n == 0:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  return ""
71
+ seen, rows = set(), []
 
72
  try:
73
+ with open(_PAIRS, encoding="utf-8") as f:
74
+ for line in f:
75
+ line = line.strip()
76
+ if not line:
77
+ continue
78
+ try:
79
+ r = json.loads(line)
80
+ except ValueError:
81
+ continue
82
+ key = (r.get("input", ""), r.get("output", ""))
83
+ if key in seen:
84
+ continue
85
+ seen.add(key)
86
+ rows.append(r)
87
+ except OSError:
88
+ return ""
89
+ stamp = dt.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
90
+ out = os.path.join(paths.DATASET_DIR, f"chan_sft_{stamp}.jsonl")
 
 
 
 
91
  try:
92
+ with open(out, "w", encoding="utf-8") as f:
93
+ for r in rows:
94
+ f.write(json.dumps(r, ensure_ascii=False) + "\n")
95
+ except OSError:
96
+ return ""
97
+ return out
 
 
 
 
 
 
 
 
 
98
 
99
 
100
+ def status_line() -> str:
101
+ n = count()
102
+ if n == 0:
103
+ return "_No fine-tuning pairs captured yet — run a few Signals AI summaries._"
104
+ return f"📚 **{n}** training pair(s) captured on /data (target: 200-500 for a good LoRA)."
 
 
 
 
 
 
 
news_watch.py CHANGED
@@ -1,189 +1,311 @@
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
+ 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 · Translator/Narrator — Qwen3-1.7B Q8 (≈2 GB RAM)
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
+ "Qwen3-1.7B · Tiny Titan (≤4B award class)": (
29
+ "Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf"),
30
+ "Qwen3-4B · default — fast + smart, still ≤4B": (
31
+ "Qwen/Qwen3-4B-GGUF", "Qwen3-4B-Q4_K_M.gguf"),
32
+ "Qwen3-8B · best balance on 8 vCPU / 32 GB": (
33
+ "Qwen/Qwen3-8B-GGUF", "Qwen3-8B-Q4_K_M.gguf"),
34
+ "Qwen3-14B · max quality (still far under 32B cap)": (
35
+ "Qwen/Qwen3-14B-GGUF", "Qwen3-14B-Q4_K_M.gguf"),
36
+ # 🎯 Well-Tuned: after you publish your LoRA-merged GGUF, uncomment and edit
37
+ # this line (repo = your HF id, filename = the .gguf you uploaded). It will
38
+ # then appear in the Model tab as the fast Translator sub-agent.
39
+ # "Chan-Tuned Qwen3-1.7B · my fine-tune": (
40
+ # "ranranrunforit/chan-compass-qwen3-1.7b-gguf", "chan-qwen3-1.7b-q8_0.gguf"),
41
+ }
42
+ FAST_MODEL = "Qwen3-1.7B · Tiny Titan (≤4B award class)"
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": "Translator 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(FAST_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 available() -> bool:
221
+ return is_loaded()
222
+
223
+
224
+ def status() -> str:
225
+ lines = []
226
+ for key in ("translator", "narrator", "reporter", "analyst"):
227
+ w = WORKERS[key]
228
+ label = WORKER_LABEL[key]
229
+ if w["llm"] is not None:
230
+ lines.append(f"✅ **{label}** — {w['model']} · llama.cpp, local")
231
+ elif w["stage"] == "idle":
232
+ lines.append(f"⚪ **{label}** — not loaded yet (auto-loads at startup)")
233
+ else:
234
+ lines.append(f"⏳ **{label}** — {w['stage']} ({w['ts']}): {w['detail'] or '…'}")
235
+ lines.append("\n_Each sub-agent has its own lock — Explain / narrative / "
236
+ "research run in parallel without “model busy”._")
237
+ return "\n\n".join(lines)
238
+
239
+
240
+ # ─────────────────────── inference ───────────────────────
241
+ DEFAULT_SYSTEM = ("You are a sub-agent of Chan Compass, a US-equity dashboard. "
242
+ "Answer in clear, concise English.")
243
+ MAX_PROMPT_CHARS = 3200
244
+
245
+
246
+ def _messages(user: str, system: str):
247
+ return [{"role": "system", "content": system + " /no_think"},
248
+ {"role": "user", "content": user[:MAX_PROMPT_CHARS]}]
249
+
250
+
251
+ def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
252
+ system: str = DEFAULT_SYSTEM, worker: str = "translator") -> str:
253
+ """Blocking chat on one sub-agent (used by pipeline/agent code)."""
254
+ w = WORKERS[_wk(worker)]; worker = _wk(worker)
255
+ if w["llm"] is None:
256
  return ""
257
+ if not w["lock"].acquire(timeout=180):
258
+ return f"({WORKER_LABEL[worker]} busy — try again in a moment)"
259
+ try:
260
+ out = w["llm"].create_chat_completion(
261
+ messages=_messages(user, system),
262
+ max_tokens=max_tokens, temperature=temperature)
263
+ txt = out["choices"][0]["message"]["content"] or ""
264
+ return _THINK_RE.sub("", txt).strip()
265
+ except Exception as e:
266
+ return f"(model error: {e})"
267
+ finally:
268
+ w["lock"].release()
269
 
270
 
271
+ def chat_stream(user: str, max_tokens: int = 500, temperature: float = 0.3,
272
+ system: str = DEFAULT_SYSTEM, worker: str = "translator"):
273
+ """Streaming chat on one sub-agent yields cumulative text immediately."""
274
+ w = WORKERS[_wk(worker)]; worker = _wk(worker)
275
+ if w["llm"] is None:
276
+ yield (f"⏳ {WORKER_LABEL[worker]} isn't ready yet "
277
+ f"stage: {w['stage']}. Check the **Model** tab.")
 
 
278
  return
279
+ if not w["lock"].acquire(timeout=5):
280
+ yield f" {WORKER_LABEL[worker]} is finishing another answer — try again in a few seconds."
281
+ return
282
+ try:
283
+ acc = ""
284
+ for chunk in w["llm"].create_chat_completion(
285
+ messages=_messages(user, system),
286
+ max_tokens=max_tokens, temperature=temperature, stream=True):
287
+ delta = chunk["choices"][0]["delta"].get("content") or ""
288
+ if not delta:
289
+ continue
290
+ acc += delta
291
+ yield _THINK_RE.sub("", acc).replace("<think>", "").strip()
292
+ if not acc.strip():
293
+ yield "(model returned no text — try again)"
294
+ except Exception as e:
295
+ yield f"(model error: {e})"
296
+ finally:
297
+ w["lock"].release()
298
+
299
+
300
+ def quick_test() -> str:
301
+ """Sanity check both sub-agents."""
302
+ import time
303
+ outs = []
304
+ for key in ("translator", "narrator", "reporter", "analyst"):
305
+ if WORKERS[key]["llm"] is None:
306
+ outs.append(f"{WORKER_LABEL[key]}: not loaded ({WORKERS[key]['stage']})")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  continue
308
+ t0 = time.time()
309
+ out = chat("Reply with exactly: OK", max_tokens=6, temperature=0.0, worker=key)
310
+ outs.append(f"{WORKER_LABEL[key]}: **{out or '(no output)'}** · {time.time()-t0:.1f}s")
311
+ return "\n\n".join(outs)
 
 
 
 
 
 
 
 
 
 
paths.py CHANGED
@@ -1,63 +1,189 @@
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
- HF_CACHE_DIR = os.path.join(DATA_ROOT, "hf_cache")
38
- PYLIBS_DIR = os.path.join(DATA_ROOT, "pylibs")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- for _d in (CACHE_DIR, OUTPUT_DIR, REPORTS_DIR, TRACES_DIR, HF_CACHE_DIR, PYLIBS_DIR):
 
41
  try:
42
- os.makedirs(_d, exist_ok=True)
43
- except OSError:
44
- pass
45
-
46
- # GGUF downloads (hf_hub_download) go to the bucket so models persist
47
- if PERSISTENT:
48
- os.environ.setdefault("HF_HOME", HF_CACHE_DIR)
49
-
50
- # llama-cpp-python installed into the bucket must be importable.
51
- # APPEND (not insert) so bucket-installed copies of common deps (numpy etc.)
52
- # can never shadow the system site-packages the app was built against.
53
- if PERSISTENT and PYLIBS_DIR not in sys.path:
54
- sys.path.append(PYLIBS_DIR)
55
-
56
-
57
- def storage_status() -> str:
58
- if PERSISTENT:
59
- return ("✅ Persistent storage bucket mounted at `/data` — data cache, "
60
- "model files, reports, traces and the llama.cpp runtime all "
61
- "survive restarts.")
62
- return ("⚠️ No `/data` bucket detected running on ephemeral container "
63
- "storage (everything re-downloads after a restart).")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.")
requirements.txt CHANGED
@@ -1,7 +1,64 @@
1
- gradio>=5.49
2
- pandas>=2.0
3
- numpy>=1.24
4
- pyarrow>=14
5
- yfinance>=0.2.40
6
- apscheduler>=3.10
7
- huggingface_hub>=0.23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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).")
research.py CHANGED
@@ -1,121 +1,7 @@
1
- """
2
- research.py — Research Note (beta): a first slice of "Auto Research / auto report".
3
-
4
- V1 scope (what this file does today):
5
- input ticker → pull fundamentals via yfinance .info + recent headlines
6
- → local LLM writes a structured English research note covering
7
- valuation, moat / supply-chain position, bull vs bear case, key risks.
8
-
9
- V2 (not built yet, see Automation tab description):
10
- multi-step agentic research (filings, peers, news crawl) and an automatic
11
- trigger that generates a report whenever a NEW ticker enters the pool.
12
- """
13
- from __future__ import annotations
14
-
15
- import traceback
16
-
17
-
18
- _FIELDS = [
19
- ("longName", "Name"), ("sector", "Sector"), ("industry", "Industry"),
20
- ("marketCap", "Market cap"), ("trailingPE", "P/E (ttm)"),
21
- ("forwardPE", "P/E (fwd)"), ("priceToSalesTrailing12Months", "P/S (ttm)"),
22
- ("priceToBook", "P/B"), ("enterpriseToEbitda", "EV/EBITDA"),
23
- ("profitMargins", "Net margin"), ("grossMargins", "Gross margin"),
24
- ("operatingMargins", "Operating margin"), ("returnOnEquity", "ROE"),
25
- ("revenueGrowth", "Revenue growth (yoy)"), ("earningsGrowth", "Earnings growth (yoy)"),
26
- ("freeCashflow", "Free cash flow"), ("totalCash", "Total cash"),
27
- ("totalDebt", "Total debt"), ("dividendYield", "Dividend yield"),
28
- ("beta", "Beta"), ("fiftyTwoWeekHigh", "52w high"), ("fiftyTwoWeekLow", "52w low"),
29
- ("currentPrice", "Price"), ("targetMeanPrice", "Analyst mean target"),
30
- ("recommendationKey", "Street view"),
31
- ]
32
-
33
-
34
- def _fmt(key: str, v):
35
- if v is None:
36
- return None
37
- try:
38
- if key in ("marketCap", "freeCashflow", "totalCash", "totalDebt"):
39
- v = float(v)
40
- return f"${v/1e9:,.1f}B" if abs(v) >= 1e9 else f"${v/1e6:,.0f}M"
41
- if key in ("profitMargins", "grossMargins", "operatingMargins", "returnOnEquity",
42
- "revenueGrowth", "earningsGrowth", "dividendYield"):
43
- return f"{float(v):.1%}"
44
- if isinstance(v, float):
45
- return f"{v:,.2f}"
46
- except (TypeError, ValueError):
47
- pass
48
- return str(v)
49
-
50
-
51
- def gather_facts(ticker: str) -> tuple:
52
- """Return (facts_markdown, facts_plain, error)."""
53
- try:
54
- import yfinance as yf
55
- tk = yf.Ticker(ticker)
56
- info = tk.info or {}
57
- except Exception as e:
58
- traceback.print_exc()
59
- return "", "", f"Could not fetch fundamentals for {ticker}: {e}"
60
- if not info or info.get("regularMarketPrice") is None and info.get("currentPrice") is None \
61
- and not info.get("longName"):
62
- return "", "", f"No fundamentals returned for {ticker} — check the symbol."
63
- rows, plain = [], []
64
- for key, label in _FIELDS:
65
- val = _fmt(key, info.get(key))
66
- if val is None:
67
- continue
68
- rows.append(f"| {label} | {val} |")
69
- plain.append(f"{label}: {val}")
70
- summary = (info.get("longBusinessSummary") or "")[:900]
71
- heads = []
72
- try:
73
- import news_watch
74
- for x in (tk.news or [])[:6]:
75
- c = x.get("content", x)
76
- t = c.get("title")
77
- if t:
78
- heads.append(t)
79
- except Exception:
80
- pass
81
- md = f"**{info.get('longName', ticker)}** ({ticker})\n\n"
82
- md += "| Metric | Value |\n|---|---|\n" + "\n".join(rows)
83
- if heads:
84
- md += "\n\n**Recent headlines:** " + " · ".join(heads[:5])
85
- plain_txt = "\n".join(plain)
86
- if summary:
87
- plain_txt += f"\nBusiness: {summary}"
88
- if heads:
89
- plain_txt += "\nRecent headlines: " + " | ".join(heads)
90
- return md, plain_txt, ""
91
-
92
-
93
- def research_note(ticker: str) -> str:
94
- """Generate the beta research note (markdown)."""
95
- ticker = (ticker or "").strip().upper()
96
- if not ticker:
97
- return "Enter a ticker symbol first."
98
- facts_md, facts_plain, err = gather_facts(ticker)
99
- if err:
100
- return f"⚠️ {err}"
101
- note = ""
102
- try:
103
- import llm_local
104
- if llm_local.is_loaded():
105
- prompt = (
106
- f"You are a buy-side equity analyst. Using ONLY the facts below, write a "
107
- f"concise English research note on {ticker} (≤400 words) with EXACTLY these "
108
- f"sections:\n"
109
- f"## Snapshot\n## Valuation\n## Moat & supply-chain position\n"
110
- f"## Bull case\n## Bear case\n## Key risks & verdict\n"
111
- f"In Valuation, judge whether multiples look cheap/fair/rich vs the growth "
112
- f"and margins shown. In Moat, infer the company's position in its industry "
113
- f"value chain. Be specific; if a fact is missing, say 'n/a' rather than "
114
- f"inventing it. No investment-advice disclaimers.\n\nFACTS:\n{facts_plain}"
115
- )
116
- note = llm_local.chat(prompt, max_tokens=900)
117
- except Exception as e:
118
- note = f"_(LLM error: {e})_"
119
- if not note:
120
- note = "_Load a model in the **Model** tab to generate the written analysis._"
121
- return f"{facts_md}\n\n---\n\n{note}\n\n_Research Note (beta) · data: Yahoo Finance · model: local GGUF_"
 
1
+ gradio>=5.49
2
+ pandas>=2.0
3
+ numpy>=1.24
4
+ pyarrow>=14
5
+ yfinance>=0.2.40
6
+ apscheduler>=3.10
7
+ huggingface_hub>=0.23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
research_agent.py CHANGED
@@ -1,424 +1,121 @@
1
  """
2
- research_agent.py — Auto Research: a multi-step agent, fully local (llama.cpp).
3
 
4
- The agent loop (every step is logged to a JSON trace → 📡 "Sharing is Caring"):
 
 
 
5
 
6
- PLAN LLM drafts 3-5 research questions for the ticker
7
- TOOLS deterministic tool calls gather evidence:
8
- t_fundamentals yfinance .info (valuation, margins, growth…)
9
- t_financials last quarterly income-statement lines
10
- t_price 52w range, MA200, drawdown, momentum (data_us)
11
- t_chan the user's Chan engine verdict (signal_runner)
12
- t_news recent headlines
13
- ANALYZE LLM writes each report section against the gathered evidence:
14
- Valuation · Moat & supply-chain position · Bull case ·
15
- Bear case · Technical timing (Chan) · Risks & verdict
16
- REPORT sections assembled into a markdown report, saved to /data/reports
17
- TRACE full step-by-step trace saved to /data/traces/<ticker>_<ts>.json
18
- (upload that folder as a HF dataset to claim the open-trace badge)
19
-
20
- Feature 4 (auto-trigger) lives in automation.py: whenever a NEW ticker enters
21
- the signal pool, this agent runs for it automatically after the daily pipeline.
22
  """
23
  from __future__ import annotations
24
 
25
- import datetime as dt
26
- import json
27
- import os
28
  import traceback
29
 
30
- import pandas as pd
31
-
32
- import paths
33
 
34
- # Multi-agent split: the 4B Analyst writes the heavy sections while the 1.7B
35
- # Reporter writes market/technical sections IN PARALLEL on its own lock —
36
- # wall-clock time the slower of the two instead of their sum.
37
- ANALYST_SECTIONS = [
38
- ("Valuation", "cheap/fair/rich vs the growth and margins in evidence; quote 2-3 numbers"),
39
- ("Technology moat", "the company's technological moat and how defensible it is"),
40
- ("Supply-chain map", "a markdown table with two columns 'Upstream suppliers' and "
41
- "'Downstream customers/users': list 4-6 real companies on each side WITH stock "
42
- "tickers in parentheses, e.g. TSMC (TSM); mark private companies (private)"),
43
- ("Bull case", "strongest 3 points FOR owning it"),
44
- ("Bear case", "strongest 3 points AGAINST owning it"),
 
 
45
  ]
46
- REPORTER_SECTIONS = [
47
- ("Money flow & related tickers", "read the MONEY FLOW evidence: is capital "
48
- "entering or leaving the stock and its sector; name the sector ETF and 2-4 "
49
- "related tickers worth watching"),
50
- ("Technical timing (Chan theory)", "interpret the CHAN ENGINE VERDICT for a "
51
- "long-term holder: act now, wait, or exit, and the key price levels"),
52
- ("Risks & verdict", "top risks, then one line: Buy / Accumulate / Hold / Avoid"),
53
- ]
54
- SECTIONS = ANALYST_SECTIONS + REPORTER_SECTIONS # kept for trace readability
55
-
56
 
57
- # ───────────────────────── trace plumbing ─────────────────────────
58
- class Trace:
59
- def __init__(self, ticker: str):
60
- self.ticker = ticker
61
- self.t0 = dt.datetime.utcnow()
62
- self.steps = []
63
 
64
- def log(self, step: str, kind: str, content):
65
- self.steps.append({
66
- "n": len(self.steps) + 1,
67
- "t": dt.datetime.utcnow().isoformat(timespec="seconds") + "Z",
68
- "step": step, "type": kind,
69
- "content": content if isinstance(content, (str, dict, list)) else str(content),
70
- })
71
-
72
- def save(self) -> str:
73
- ts = self.t0.strftime("%Y%m%d-%H%M%S")
74
- path = os.path.join(paths.TRACES_DIR, f"{self.ticker}_{ts}.json")
75
- try:
76
- with open(path, "w", encoding="utf-8") as f:
77
- json.dump({"ticker": self.ticker, "agent": "chan-compass-research",
78
- "model_runtime": "llama.cpp (local)",
79
- "started": self.t0.isoformat() + "Z",
80
- "steps": self.steps}, f, ensure_ascii=False, indent=1, default=str)
81
- return path
82
- except OSError:
83
- return ""
84
-
85
-
86
- def _llm(prompt: str, max_tokens: int = 500) -> str:
87
  try:
88
- import llm_local
89
- if llm_local.is_loaded("deep"):
90
- return llm_local.chat(prompt, max_tokens=max_tokens, worker="deep")
91
- if llm_local.is_loaded("fast"): # deep still loading → fall back
92
- return llm_local.chat(prompt, max_tokens=max_tokens, worker="fast")
93
- except Exception:
 
 
 
94
  pass
95
- return ""
96
-
97
 
98
- # ───────────────────────── tools ─────────────────────────
99
- def t_fundamentals(ticker: str) -> dict:
100
- import research
101
- md, plain, err = research.gather_facts(ticker)
102
- return {"ok": not err, "markdown": md, "plain": plain, "error": err}
103
 
104
-
105
- def t_financials(ticker: str) -> str:
106
  try:
107
  import yfinance as yf
108
- q = yf.Ticker(ticker).quarterly_income_stmt
109
- if q is None or q.empty:
110
- return ""
111
- rows = [r for r in ("Total Revenue", "Gross Profit", "Operating Income",
112
- "Net Income") if r in q.index]
113
- sub = q.loc[rows].iloc[:, :4]
114
- out = []
115
- for r in rows:
116
- vals = ", ".join(f"{c.strftime('%Y-%m')}: ${v/1e9:,.2f}B"
117
- for c, v in sub.loc[r].items() if pd.notna(v))
118
- out.append(f"{r} {vals}")
119
- return "\n".join(out)
120
- except Exception:
121
- return ""
122
-
123
-
124
- def t_price(ticker: str) -> str:
125
- try:
126
- import data_us
127
- d = data_us.load_level(ticker, "d")
128
- if d is None or len(d) < 60:
129
- return ""
130
- px = float(d["close"].iloc[-1])
131
- hi52 = float(d["close"].tail(252).max()); lo52 = float(d["close"].tail(252).min())
132
- ma200 = float(d["close"].rolling(200).mean().iloc[-1])
133
- r3m = px / float(d["close"].iloc[-63]) - 1 if len(d) > 63 else 0
134
- r1y = px / float(d["close"].iloc[-252]) - 1 if len(d) > 252 else 0
135
- return (f"Price ${px:,.2f} | 52w range ${lo52:,.2f}–${hi52:,.2f} "
136
- f"({(px/hi52-1):+.1%} off high) | vs MA200 {(px/ma200-1):+.1%} | "
137
- f"3m {r3m:+.1%}, 1y {r1y:+.1%}")
138
- except Exception:
139
- return ""
140
-
141
-
142
- def t_chan(ticker: str) -> str:
143
- try:
144
- import signal_runner
145
- row, _ = signal_runner.analyze_one(ticker)
146
- if not row:
147
- return ""
148
- return (f"Tomorrow: {row['Tomorrow']} | signal {row['Signal']} | "
149
- f"confidence {row['Confidence']} | buy zone {row['Buy zone']} | "
150
- f"invalid below {row['Invalid below']} | note: {row['Note']}")
151
- except Exception:
152
- return ""
153
-
154
-
155
- def t_flows(ticker: str) -> str:
156
- """Money-flow proxy (Δ% × dollar volume) for the ticker and its sector ETF,
157
- 1/5/20-day windows, plus related tickers via the sector mapping."""
158
- try:
159
- import data_us
160
- import rotation as rot
161
- out = []
162
- d = data_us.load_level(ticker, "d")
163
- for n, lab in ((1, "1D"), (5, "5D"), (20, "20D")):
164
- st = rot._window_stats(d, n)
165
- if st:
166
- pct, dvol, flow = st
167
- out.append(f"{ticker} {lab}: {pct:+.2%}, flow proxy "
168
- f"${flow/1e6:+,.0f}M on ${dvol/1e9:,.1f}B avg $vol")
169
- sector = ""
170
- try:
171
- import yfinance as yf
172
- sector = (yf.Ticker(ticker).info or {}).get("sector", "")
173
- except Exception:
174
- pass
175
- etf = next((k for k, v in rot.SECTOR_ETFS.items() if v == sector), None)
176
- if etf:
177
- de = data_us.load_level(etf, "d")
178
- st = rot._window_stats(de, 5)
179
- if st:
180
- out.append(f"Sector ETF {etf} ({sector}) 5D: {st[0]:+.2%}, "
181
- f"flow proxy ${st[2]/1e6:+,.0f}M")
182
- return "\n".join(out)
183
- except Exception:
184
- return ""
185
-
186
-
187
- def t_news(ticker: str) -> str:
188
  try:
189
- import yfinance as yf
190
- heads = []
191
- for x in (yf.Ticker(ticker).news or [])[:8]:
192
  c = x.get("content", x)
193
  t = c.get("title")
194
  if t:
195
- heads.append("- " + t)
196
- return "\n".join(heads)
197
  except Exception:
198
- return ""
199
-
200
-
201
- # ───────────────────────── the agent ─────────────────────────
202
- def _gather_evidence(ticker: str, tr: "Trace", on_step=None) -> dict:
203
- """Run all evidence tools IN PARALLEL (network-bound) — was serial before."""
204
- from concurrent.futures import ThreadPoolExecutor
205
- tools = {"fundamentals": t_fundamentals, "financials": t_financials,
206
- "price": t_price, "chan_engine": t_chan, "flows": t_flows,
207
- "news": t_news}
208
- evidence = {}
209
- with ThreadPoolExecutor(max_workers=6) as ex:
210
- futs = {name: ex.submit(fn, ticker) for name, fn in tools.items()}
211
- for name, fut in futs.items():
212
- try:
213
- evidence[name] = fut.result(timeout=40)
214
- except Exception as e:
215
- evidence[name] = ""
216
- tr.log(f"TOOL:{name}", "tool_error", str(e))
217
- brief = (evidence[name].get("plain", "")[:300]
218
- if isinstance(evidence[name], dict) else str(evidence[name])[:300])
219
- tr.log(f"TOOL:{name}", "tool_result", brief or "(empty)")
220
- if on_step:
221
- on_step(name)
222
- return evidence
223
-
224
-
225
- def _evidence_text(evidence: dict) -> str:
226
- fund = evidence.get("fundamentals")
227
- return (f"FUNDAMENTALS:\n{(fund.get('plain','') if isinstance(fund, dict) else '')[:1000]}\n\n"
228
- f"QUARTERLY FINANCIALS:\n{str(evidence.get('financials'))[:380] or 'n/a'}\n\n"
229
- f"PRICE ACTION:\n{evidence.get('price') or 'n/a'}\n\n"
230
- f"MONEY FLOW:\n{str(evidence.get('flows'))[:420] or 'n/a'}\n\n"
231
- f"CHAN ENGINE VERDICT:\n{str(evidence.get('chan_engine'))[:380] or 'n/a'}\n\n"
232
- f"RECENT HEADLINES:\n{str(evidence.get('news'))[:380] or 'n/a'}")[:2500]
233
-
234
-
235
- def _sections_prompt(ticker: str, sections, ev_text: str) -> str:
236
- sec_list = "\n".join(f"## {t} — {i}" for t, i in sections)
237
- return (f"You are a buy-side equity analyst. Write part of a research note on "
238
- f"{ticker} using ONLY the evidence below (write 'n/a' for missing facts, "
239
- f"never invent numbers; company names in the supply-chain table may come "
240
- f"from your own industry knowledge). Output EXACTLY these markdown "
241
- f"sections, ≤70 words each, plain prose, ENGLISH ONLY, no disclaimers:\n"
242
- f"{sec_list}\n\nEVIDENCE:\n{ev_text}")
243
-
244
-
245
- _STATIC_PLAN = ("1. Is the valuation justified by growth?\n"
246
- "2. How durable is the technology moat and supply-chain position?\n"
247
- "3. Where is capital flowing — into or out of the name and sector?\n"
248
- "4. Is now a good technical entry for a long-term holder?")
249
-
250
-
251
- def run_research(ticker: str, auto: bool = False) -> tuple:
252
- """Blocking multi-agent run (used by the daily pipeline).
253
- Analyst (4B) and Reporter (1.7B) write their sections in PARALLEL.
254
- Returns (report_markdown, trace_path). If no model is ready, returns
255
- ('', '') so the caller can postpone instead of saving an empty report."""
256
- import llm_local
257
- ticker = (ticker or "").strip().upper()
258
- if not ticker:
259
- return "Enter a ticker symbol first.", ""
260
- if not (llm_local.is_loaded("analyst") or llm_local.is_loaded("reporter")):
261
- return "", "" # postpone — model still loading
262
- tr = Trace(ticker)
263
- tr.log("PLAN", "static", _STATIC_PLAN)
264
- evidence = _gather_evidence(ticker, tr)
265
- fund = evidence.get("fundamentals")
266
- if isinstance(fund, dict) and not fund.get("ok"):
267
- tr.save()
268
- return f"⚠️ {fund.get('error', 'Could not fetch data.')}", ""
269
- ev_text = _evidence_text(evidence)
270
-
271
- import threading
272
- parts = {}
273
-
274
- def _write(slot, sections, worker, fallback_worker):
275
- wk = worker if llm_local.is_loaded(worker) else fallback_worker
276
- tr.log(f"ANALYZE:{slot}", "llm_request", f"worker={wk}")
277
- txt = llm_local.chat(_sections_prompt(ticker, sections, ev_text),
278
- max_tokens=620 if slot == "analyst" else 360, worker=wk)
279
- if txt.startswith("(") or txt.startswith("⏳"):
280
- txt = ""
281
- tr.log(f"ANALYZE:{slot}", "llm_response", txt[:500])
282
- parts[slot] = txt
283
-
284
- th = threading.Thread(target=_write,
285
- args=("reporter", REPORTER_SECTIONS, "reporter", "analyst"))
286
- th.start()
287
- _write("analyst", ANALYST_SECTIONS, "analyst", "reporter")
288
- th.join(timeout=300)
289
-
290
- fund_md = fund.get("markdown", "") if isinstance(fund, dict) else ""
291
- stamp = dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
292
- head = (f"# {ticker} — Research Note{' (auto-generated)' if auto else ''}\n"
293
- f"_{stamp} · multi-agent: Analyst (Qwen3-4B) + Reporter (Qwen3-1.7B), "
294
- f"both llama.cpp local_\n\n**Agent plan:**\n{_STATIC_PLAN}\n\n{fund_md}\n\n---\n")
295
- body = "\n\n".join(p for p in (parts.get("analyst"), parts.get("reporter")) if p)
296
- if not body:
297
- tr.save()
298
- return "", "" # model produced nothing — postpone, never save a stub
299
- report = head + body + "\n\n---\n_Agent trace saved — see the Automation tab._"
300
- tr.log("REPORT", "assembled", f"{len(report)} chars")
301
- trace_path = tr.save()
302
- try:
303
- rp = os.path.join(paths.REPORTS_DIR, f"{ticker}_{tr.t0.strftime('%Y%m%d')}.md")
304
- with open(rp, "w", encoding="utf-8") as f:
305
- f.write(report)
306
- except OSError:
307
  pass
308
- return report, trace_path
309
-
310
-
311
- def list_reports() -> list:
312
- try:
313
- fs = [f for f in os.listdir(paths.REPORTS_DIR) if f.endswith(".md")]
314
- return sorted(fs, reverse=True)
315
- except OSError:
316
- return []
317
-
318
-
319
- def read_report(fname: str) -> str:
320
- if not fname:
321
- return "No report selected."
322
- try:
323
- with open(os.path.join(paths.REPORTS_DIR, os.path.basename(fname)),
324
- encoding="utf-8") as f:
325
- return f.read()
326
- except OSError as e:
327
- return f"Could not read report: {e}"
328
-
329
-
330
- def list_traces() -> str:
331
- try:
332
- fs = sorted(os.listdir(paths.TRACES_DIR), reverse=True)[:20]
333
- if not fs:
334
- return "_No traces yet — run a research note first._"
335
- return ("**Saved agent traces** (`" + paths.TRACES_DIR + "`):\n" +
336
- "\n".join(f"- `{f}`" for f in fs) +
337
- "\n\n📡 To claim the open-trace badge: download this folder and "
338
- "push it to the Hub as a dataset (`huggingface-cli upload`).")
339
- except OSError:
340
- return "_Trace folder unavailable._"
341
-
342
-
343
- # ───────────────────────── streaming UI runner ─────────────────────────
344
- def run_research_stream(ticker: str):
345
- """Generator for the UI. Multi-agent: evidence tools run in parallel; the
346
- 1.7B Reporter writes its sections in a background thread while the 4B
347
- Analyst STREAMS its sections live; never saves a 'model busy' stub."""
348
- import threading
349
- import llm_local
350
  ticker = (ticker or "").strip().upper()
351
  if not ticker:
352
- yield "Enter a ticker symbol first.", ""
353
- return
354
- tr = Trace(ticker)
355
- log_lines = [f"### 🤖 Multi-agent research · {ticker}"]
356
-
357
- def show(msg):
358
- log_lines.append(f"- {msg}")
359
- return "\n".join(log_lines)
360
-
361
- tr.log("PLAN", "static", _STATIC_PLAN)
362
- yield show("**PLAN** ready ✓ · **TOOLS** — gathering 6 evidence sources in parallel…"), ""
363
- evidence = _gather_evidence(ticker, tr)
364
- yield show("Evidence in: fundamentals · financials · price · money flow · Chan engine · news ✓"), ""
365
-
366
- fund = evidence.get("fundamentals")
367
- if isinstance(fund, dict) and not fund.get("ok"):
368
- tr.save()
369
- yield show(f"⚠️ {fund.get('error', 'Data fetch failed.')}"), ""
370
- return
371
- ev_text = _evidence_text(evidence)
372
- fund_md = fund.get("markdown", "") if isinstance(fund, dict) else ""
373
- stamp = dt.datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
374
- head = (f"# {ticker} — Research Note\n_{stamp} · multi-agent: Analyst (Qwen3-4B) "
375
- f"+ Reporter (Qwen3-1.7B), both llama.cpp local_\n\n"
376
- f"**Agent plan:**\n{_STATIC_PLAN}\n\n{fund_md}\n\n---\n")
377
-
378
- # Reporter sub-agent works in parallel on its own lock
379
- side = {"txt": ""}
380
-
381
- def _side():
382
- wk = "reporter" if llm_local.is_loaded("reporter") else "analyst"
383
- t = llm_local.chat(_sections_prompt(ticker, REPORTER_SECTIONS, ev_text),
384
- max_tokens=360, worker=wk)
385
- side["txt"] = "" if (t.startswith("(") or t.startswith("⏳")) else t
386
- tr.log("ANALYZE:reporter", "llm_response", side["txt"][:400])
387
-
388
- th = None
389
- if llm_local.is_loaded("reporter") or llm_local.is_loaded("analyst"):
390
- th = threading.Thread(target=_side, daemon=True)
391
- th.start()
392
- yield show("**Reporter sub-agent** writing money-flow / Chan timing / verdict "
393
- "in parallel…"), head
394
- # Analyst streams the main sections live
395
- main = ""
396
- wk_main = "analyst" if llm_local.is_loaded("analyst") else "reporter"
397
- if llm_local.is_loaded(wk_main):
398
- yield show(f"**Analyst sub-agent** streaming valuation / moat / supply-chain "
399
- f"map / bull-bear…"), head
400
- for acc in llm_local.chat_stream(
401
- _sections_prompt(ticker, ANALYST_SECTIONS, ev_text),
402
- max_tokens=620, worker=wk_main):
403
- if acc.startswith("⏳") or acc.startswith("("):
404
- continue
405
- main = acc
406
- yield "\n".join(log_lines), head + main
407
- tr.log("ANALYZE:analyst", "llm_response", main[:500])
408
- if th is not None:
409
- th.join(timeout=240)
410
- body = "\n\n".join(p for p in (main, side["txt"]) if p)
411
- if not body:
412
- tr.save()
413
- yield show("⚠️ Sub-agents not ready yet (still loading) — evidence gathered "
414
- "above; try again in a minute. Nothing was saved."), head
415
- return
416
- report = head + body + "\n\n---\n_Agent trace saved — see the Automation tab._"
417
- trace_path = tr.save()
418
  try:
419
- rp = os.path.join(paths.REPORTS_DIR, f"{ticker}_{tr.t0.strftime('%Y%m%d')}.md")
420
- with open(rp, "w", encoding="utf-8") as f:
421
- f.write(report)
422
- except OSError:
423
- pass
424
- yield show(f"**DONE** ✓ report + trace saved (`{os.path.basename(trace_path)}`)"), report
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ research.py — Research Note (beta): a first slice of "Auto Research / auto report".
3
 
4
+ V1 scope (what this file does today):
5
+ input ticker → pull fundamentals via yfinance .info + recent headlines
6
+ → local LLM writes a structured English research note covering
7
+ valuation, moat / supply-chain position, bull vs bear case, key risks.
8
 
9
+ V2 (not built yet, see Automation tab description):
10
+ multi-step agentic research (filings, peers, news crawl) and an automatic
11
+ trigger that generates a report whenever a NEW ticker enters the pool.
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  """
13
  from __future__ import annotations
14
 
 
 
 
15
  import traceback
16
 
 
 
 
17
 
18
+ _FIELDS = [
19
+ ("longName", "Name"), ("sector", "Sector"), ("industry", "Industry"),
20
+ ("marketCap", "Market cap"), ("trailingPE", "P/E (ttm)"),
21
+ ("forwardPE", "P/E (fwd)"), ("priceToSalesTrailing12Months", "P/S (ttm)"),
22
+ ("priceToBook", "P/B"), ("enterpriseToEbitda", "EV/EBITDA"),
23
+ ("profitMargins", "Net margin"), ("grossMargins", "Gross margin"),
24
+ ("operatingMargins", "Operating margin"), ("returnOnEquity", "ROE"),
25
+ ("revenueGrowth", "Revenue growth (yoy)"), ("earningsGrowth", "Earnings growth (yoy)"),
26
+ ("freeCashflow", "Free cash flow"), ("totalCash", "Total cash"),
27
+ ("totalDebt", "Total debt"), ("dividendYield", "Dividend yield"),
28
+ ("beta", "Beta"), ("fiftyTwoWeekHigh", "52w high"), ("fiftyTwoWeekLow", "52w low"),
29
+ ("currentPrice", "Price"), ("targetMeanPrice", "Analyst mean target"),
30
+ ("recommendationKey", "Street view"),
31
  ]
 
 
 
 
 
 
 
 
 
 
32
 
 
 
 
 
 
 
33
 
34
+ def _fmt(key: str, v):
35
+ if v is None:
36
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  try:
38
+ if key in ("marketCap", "freeCashflow", "totalCash", "totalDebt"):
39
+ v = float(v)
40
+ return f"${v/1e9:,.1f}B" if abs(v) >= 1e9 else f"${v/1e6:,.0f}M"
41
+ if key in ("profitMargins", "grossMargins", "operatingMargins", "returnOnEquity",
42
+ "revenueGrowth", "earningsGrowth", "dividendYield"):
43
+ return f"{float(v):.1%}"
44
+ if isinstance(v, float):
45
+ return f"{v:,.2f}"
46
+ except (TypeError, ValueError):
47
  pass
48
+ return str(v)
 
49
 
 
 
 
 
 
50
 
51
+ def gather_facts(ticker: str) -> tuple:
52
+ """Return (facts_markdown, facts_plain, error)."""
53
  try:
54
  import yfinance as yf
55
+ tk = yf.Ticker(ticker)
56
+ info = tk.info or {}
57
+ except Exception as e:
58
+ traceback.print_exc()
59
+ return "", "", f"Could not fetch fundamentals for {ticker}: {e}"
60
+ if not info or info.get("regularMarketPrice") is None and info.get("currentPrice") is None \
61
+ and not info.get("longName"):
62
+ return "", "", f"No fundamentals returned for {ticker} check the symbol."
63
+ rows, plain = [], []
64
+ for key, label in _FIELDS:
65
+ val = _fmt(key, info.get(key))
66
+ if val is None:
67
+ continue
68
+ rows.append(f"| {label} | {val} |")
69
+ plain.append(f"{label}: {val}")
70
+ summary = (info.get("longBusinessSummary") or "")[:900]
71
+ heads = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  try:
73
+ import news_watch
74
+ for x in (tk.news or [])[:6]:
 
75
  c = x.get("content", x)
76
  t = c.get("title")
77
  if t:
78
+ heads.append(t)
 
79
  except Exception:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  pass
81
+ md = f"**{info.get('longName', ticker)}** ({ticker})\n\n"
82
+ md += "| Metric | Value |\n|---|---|\n" + "\n".join(rows)
83
+ if heads:
84
+ md += "\n\n**Recent headlines:** " + " · ".join(heads[:5])
85
+ plain_txt = "\n".join(plain)
86
+ if summary:
87
+ plain_txt += f"\nBusiness: {summary}"
88
+ if heads:
89
+ plain_txt += "\nRecent headlines: " + " | ".join(heads)
90
+ return md, plain_txt, ""
91
+
92
+
93
+ def research_note(ticker: str) -> str:
94
+ """Generate the beta research note (markdown)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  ticker = (ticker or "").strip().upper()
96
  if not ticker:
97
+ return "Enter a ticker symbol first."
98
+ facts_md, facts_plain, err = gather_facts(ticker)
99
+ if err:
100
+ return f"⚠️ {err}"
101
+ note = ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  try:
103
+ import llm_local
104
+ if llm_local.is_loaded():
105
+ prompt = (
106
+ f"You are a buy-side equity analyst. Using ONLY the facts below, write a "
107
+ f"concise English research note on {ticker} (≤400 words) with EXACTLY these "
108
+ f"sections:\n"
109
+ f"## Snapshot\n## Valuation\n## Moat & supply-chain position\n"
110
+ f"## Bull case\n## Bear case\n## Key risks & verdict\n"
111
+ f"In Valuation, judge whether multiples look cheap/fair/rich vs the growth "
112
+ f"and margins shown. In Moat, infer the company's position in its industry "
113
+ f"value chain. Be specific; if a fact is missing, say 'n/a' rather than "
114
+ f"inventing it. No investment-advice disclaimers.\n\nFACTS:\n{facts_plain}"
115
+ )
116
+ note = llm_local.chat(prompt, max_tokens=900)
117
+ except Exception as e:
118
+ note = f"_(LLM error: {e})_"
119
+ if not note:
120
+ note = "_Load a model in the **Model** tab to generate the written analysis._"
121
+ return f"{facts_md}\n\n---\n\n{note}\n\n_Research Note (beta) · data: Yahoo Finance · model: local GGUF_"