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