Spaces:
Sleeping
Sleeping
Upload 24 files
Browse files- app.py +46 -483
- automation.py +60 -146
- base.css +13 -0
- chan_engine.py +508 -1445
- chan_enhance.py +141 -117
- chan_glue.py +1445 -64
- chan_multilevel.py +119 -880
- data_us.py +63 -140
- elevation.css +58 -0
- emailer.py +871 -275
- finetune_data.py +136 -94
- fonts.css +148 -0
- llm_local.py +271 -290
- news_watch.py +83 -167
- paths.py +286 -43
- spacing.css +35 -0
- styles.css +174 -0
- theme.py +82 -48
- typography.css +12 -0
app.py
CHANGED
|
@@ -1,483 +1,46 @@
|
|
| 1 |
-
|
| 2 |
-
Chan Compass
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
choices = sorted(details.keys())
|
| 48 |
-
return (
|
| 49 |
-
df if df is not None else pd.DataFrame(),
|
| 50 |
-
f"{summary} · {time.time()-t0:.1f}s (rule engine only — no LLM in this path)",
|
| 51 |
-
gr.update(choices=choices, value=(choices[0] if choices else None)),
|
| 52 |
-
)
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
def ui_show_detail(ticker):
|
| 56 |
-
if not ticker:
|
| 57 |
-
return "Select a ticker after running the analysis."
|
| 58 |
-
raw = signal_runner.stock_raw_read(ticker)
|
| 59 |
-
if not raw:
|
| 60 |
-
return "No data for this ticker yet — run the analysis first."
|
| 61 |
-
return f"**Raw read:**\n\n{raw}"
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def ui_explain_detail(ticker):
|
| 65 |
-
raw = signal_runner.stock_raw_read(ticker or "")
|
| 66 |
-
if not raw:
|
| 67 |
-
yield "Run the analysis and select a ticker first."
|
| 68 |
-
return
|
| 69 |
-
# The full Chan ruling chain (Chinese) is kept BACKSTAGE in STATE and fed to
|
| 70 |
-
# the model alongside the English raw read, so the summary reflects the real
|
| 71 |
-
# multi-timeframe reasoning — but the chain is never shown and output is
|
| 72 |
-
# English only. (This restores the merged raw-read + ruling-chain logic.)
|
| 73 |
-
chain = automation.STATE.get("signals_details", {}).get(ticker or "", "")
|
| 74 |
-
chain_core = chain.split("日线买卖点逐项诊断")[0].strip()[:2000] if chain else ""
|
| 75 |
-
prompt = ("You are an equity analyst. Write a SHORT plain-English summary "
|
| 76 |
-
"(≤100 words) for a long-term holder of a US stock: the situation "
|
| 77 |
-
"today, whether to act or wait, and the key price levels.\n"
|
| 78 |
-
"Use the FACT LINE for the numbers, and the RULING CHAIN (a "
|
| 79 |
-
"Chinese multi-timeframe Chan-theory decision log) for the reasoning "
|
| 80 |
-
"— translate and synthesize it; output ENGLISH ONLY, no Chinese "
|
| 81 |
-
"characters, do not quote the log, no disclaimers.\n\n"
|
| 82 |
-
f"FACT LINE:\n{raw}")
|
| 83 |
-
if chain_core:
|
| 84 |
-
prompt += f"\n\nRULING CHAIN (translate & synthesize, don't quote):\n{chain_core}"
|
| 85 |
-
yield "🤖 _Summary Sub-Agent (Chan-Tuned Qwen3-1.7B · llama.cpp) is explaining…_"
|
| 86 |
-
final = ""
|
| 87 |
-
for acc in llm_local.chat_stream(prompt, max_tokens=260, temperature=0.2,
|
| 88 |
-
worker="translator"):
|
| 89 |
-
final = acc
|
| 90 |
-
yield "🤖 **AI Summary (Summary Sub-Agent · Chan-Tuned Qwen3-1.7B):**\n\n" + acc
|
| 91 |
-
# capture (raw read → narrative) as a fine-tuning pair (🎯 Well-Tuned)
|
| 92 |
-
try:
|
| 93 |
-
import finetune_data
|
| 94 |
-
finetune_data.record(raw, final)
|
| 95 |
-
except Exception:
|
| 96 |
-
pass
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def ui_refresh_rotation():
|
| 100 |
-
"""Tables only — instant. AI narrative is a separate on-demand button."""
|
| 101 |
-
d1, d5, d20, asof = rotation.build_rotation(force=True)
|
| 102 |
-
automation.STATE["rotation"] = (d1, d5, d20, asof)
|
| 103 |
-
raw = rotation.rotation_brief(d1, d5, d20)
|
| 104 |
-
automation.STATE["rotation_narrative"] = raw
|
| 105 |
-
return (rotation.fmt_table(d1), rotation.fmt_table(d5), rotation.fmt_table(d20),
|
| 106 |
-
f"Sector flows as of **{asof}**", f"**Raw read:**\n{raw}")
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
def ui_rotation_ai():
|
| 110 |
-
d1, d5, d20, asof = automation.STATE["rotation"]
|
| 111 |
-
if d1 is None:
|
| 112 |
-
yield "Refresh the rotation tables first."
|
| 113 |
-
return
|
| 114 |
-
brief = rotation.rotation_brief(d1, d5, d20)
|
| 115 |
-
prompt = ("You are a US equity market strategist. Based only on the sector flow "
|
| 116 |
-
"data below (SPDR ETF proxy: change% × dollar volume, plus RS vs SPY), "
|
| 117 |
-
"write a crisp brief (<150 words): 1) where capital is rotating INTO/OUT "
|
| 118 |
-
"OF; 2) do 1-day moves agree with the 5/20-day trend; 3) one watch item. "
|
| 119 |
-
"No disclaimers.\n\nDATA:\n" + brief[:2200])
|
| 120 |
-
yield ("🤖 _Narrator sub-agent (Qwen3-1.7B · llama.cpp) is reading the flow "
|
| 121 |
-
"tables — first words in ~5-15s…_")
|
| 122 |
-
for acc in llm_local.chat_stream(prompt, max_tokens=340, worker="narrator"):
|
| 123 |
-
yield "🤖 **Narrator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
def ui_save_holdings(text):
|
| 127 |
-
saved = news_watch.save_holdings(text.replace("\n", ",").split(","))
|
| 128 |
-
return f"Saved {len(saved)} holding(s): {', '.join(saved) if saved else '—'}"
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
def ui_check_news():
|
| 132 |
-
last = ""
|
| 133 |
-
for md in news_watch.check_holdings_news_stream():
|
| 134 |
-
last = md
|
| 135 |
-
yield md
|
| 136 |
-
automation.STATE["news_md"] = last
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def ui_research(ticker):
|
| 140 |
-
progress, report = "", ""
|
| 141 |
-
for progress, report in research_agent.run_research_stream(ticker):
|
| 142 |
-
yield progress, report, gr.update()
|
| 143 |
-
reports = research_agent.list_reports()
|
| 144 |
-
newest = reports[0] if reports else None
|
| 145 |
-
yield progress, report, gr.update(choices=reports, value=newest)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
def ui_open_report(fname):
|
| 149 |
-
return research_agent.read_report(fname)
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
def ui_load_model(name):
|
| 153 |
-
return llm_local.load_model(name, worker="deep")
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
_SELFTEST = {"done": False, "result": ""}
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def _publish_traces(repo_id: str):
|
| 161 |
-
"""One-click: upload /data/traces to the Hub as a dataset, using the
|
| 162 |
-
HF_TOKEN secret. No command line needed."""
|
| 163 |
-
repo_id = (repo_id or "").strip()
|
| 164 |
-
if "/" not in repo_id:
|
| 165 |
-
return "⚠️ Enter a repo id like `username/dataset-name`."
|
| 166 |
-
token = (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 167 |
-
or "").strip()
|
| 168 |
-
if not token:
|
| 169 |
-
return ("⚠️ No `HF_TOKEN` secret found. Add it in Space → Settings → "
|
| 170 |
-
"Variables and secrets (a **write** token), then try again.")
|
| 171 |
-
try:
|
| 172 |
-
import paths
|
| 173 |
-
traces = [f for f in os.listdir(paths.TRACES_DIR) if f.endswith(".json")]
|
| 174 |
-
except OSError:
|
| 175 |
-
traces = []
|
| 176 |
-
if not traces:
|
| 177 |
-
return "⚠️ No traces yet — run a few Auto Research reports first."
|
| 178 |
-
try:
|
| 179 |
-
from huggingface_hub import HfApi
|
| 180 |
-
api = HfApi(token=token)
|
| 181 |
-
api.create_repo(repo_id, repo_type="dataset", exist_ok=True)
|
| 182 |
-
# a small README so the dataset page explains itself
|
| 183 |
-
card = (
|
| 184 |
-
"---\nlicense: mit\ntags: [agent-trace, finance, chan-theory, llama-cpp]\n---\n\n"
|
| 185 |
-
"# Chan Compass — agent traces\n\n"
|
| 186 |
-
"JSON traces from the Chan Compass multi-agent research desk. Each file "
|
| 187 |
-
"is one ticker's run: the plan, every evidence-tool call and its result, "
|
| 188 |
-
"and each local sub-agent's request and response. Shared for the "
|
| 189 |
-
"Build Small hackathon (*Sharing is Caring*).\n\n"
|
| 190 |
-
"*Educational data — not investment advice.*\n")
|
| 191 |
-
import tempfile
|
| 192 |
-
rp = os.path.join(tempfile.gettempdir(), "README.md")
|
| 193 |
-
with open(rp, "w", encoding="utf-8") as f:
|
| 194 |
-
f.write(card)
|
| 195 |
-
api.upload_file(path_or_fileobj=rp, path_in_repo="README.md",
|
| 196 |
-
repo_id=repo_id, repo_type="dataset")
|
| 197 |
-
api.upload_folder(folder_path=paths.TRACES_DIR, path_in_repo="traces",
|
| 198 |
-
repo_id=repo_id, repo_type="dataset")
|
| 199 |
-
url = f"https://huggingface.co/datasets/{repo_id}"
|
| 200 |
-
return (f"✅ Published **{len(traces)}** trace(s) to [{repo_id}]({url}). "
|
| 201 |
-
f"Put that link in your submission for the *Sharing is Caring* badge.")
|
| 202 |
-
except Exception as e:
|
| 203 |
-
return f"❌ Upload failed: {e}"
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
def _export_dataset():
|
| 207 |
-
n = finetune_data.count()
|
| 208 |
-
if n == 0:
|
| 209 |
-
return ("⚠️ **0 training pairs captured yet.** Pairs are saved only when "
|
| 210 |
-
"the **Signals → AI summary** finishes with a model loaded. Steps: "
|
| 211 |
-
"1) Model tab — wait for the Summary sub-agent to show ✅; "
|
| 212 |
-
"2) Signals — Run analysis, pick a ticker, click **AI summary**, "
|
| 213 |
-
"let it finish; repeat a few times; 3) come back and Export.",
|
| 214 |
-
gr.update(visible=False))
|
| 215 |
-
path = finetune_data.export()
|
| 216 |
-
if not path:
|
| 217 |
-
return ("⚠️ Export failed to write the file (storage error). Try again.",
|
| 218 |
-
gr.update(visible=False))
|
| 219 |
-
# Copy to a folder under the app's working dir, which Gradio serves
|
| 220 |
-
# reliably (the /data bucket and /tmp are not in Gradio's allowed paths).
|
| 221 |
-
import shutil
|
| 222 |
-
served_dir = os.path.join(os.getcwd(), "exports")
|
| 223 |
-
os.makedirs(served_dir, exist_ok=True)
|
| 224 |
-
served = os.path.join(served_dir, os.path.basename(path))
|
| 225 |
-
try:
|
| 226 |
-
shutil.copy(path, served)
|
| 227 |
-
except OSError:
|
| 228 |
-
served = path
|
| 229 |
-
msg = (f"✅ Exported **{n}** captured pair(s). Download below, then follow "
|
| 230 |
-
f"`finetune/FINETUNE_GUIDE.md` to LoRA-tune Qwen3-1.7B and publish it.")
|
| 231 |
-
return msg, gr.update(value=served, visible=True)
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
def _run_selftest():
|
| 235 |
-
"""Run the self-test now (used by both the auto-timer and the manual button)
|
| 236 |
-
and cache the verdict so the two never fight over the output box."""
|
| 237 |
-
out = llm_local.quick_test()
|
| 238 |
-
ok = "not loaded" not in out and "error" not in out.lower()
|
| 239 |
-
_SELFTEST["done"] = True
|
| 240 |
-
_SELFTEST["result"] = (("✅ **Every agent is OK now** — self-test passed:\n\n" + out)
|
| 241 |
-
if ok else ("⚠️ Self-test finished with issues:\n\n" + out))
|
| 242 |
-
return _SELFTEST["result"]
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
def _auto_selftest():
|
| 246 |
-
"""Auto-runs ONCE the moment every sub-agent is loaded. After that it returns
|
| 247 |
-
the cached verdict unchanged, so it never overwrites a manual test result."""
|
| 248 |
-
if _SELFTEST["done"]:
|
| 249 |
-
return _SELFTEST["result"]
|
| 250 |
-
workers = llm_local.WORKERS
|
| 251 |
-
if not all(w["llm"] is not None for w in workers.values()):
|
| 252 |
-
ready = sum(1 for w in workers.values() if w["llm"] is not None)
|
| 253 |
-
return f"⏳ Loading sub-agents… {ready}/{len(workers)} ready (self-test will run automatically)."
|
| 254 |
-
return _run_selftest()
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
def _manual_selftest():
|
| 258 |
-
"""Manual button: always re-runs and shows the fresh verdict."""
|
| 259 |
-
return _run_selftest()
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
# ─────────────────────────────────────────── layout ──
|
| 263 |
-
_GR_MAJOR = int(gr.__version__.split(".")[0])
|
| 264 |
-
_style_kw = {} if _GR_MAJOR >= 6 else {"theme": theme, "css": S2_CSS}
|
| 265 |
-
|
| 266 |
-
with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
| 267 |
-
gr.HTML("""
|
| 268 |
-
<div id="s2-hero">
|
| 269 |
-
<div class="mark">🧭</div>
|
| 270 |
-
<div>
|
| 271 |
-
<h1>Chan Compass <span>· US Markets</span></h1>
|
| 272 |
-
<p>Multi-timeframe 缠论 (Chan theory) engine — monthly → 1m nested-interval
|
| 273 |
-
confirmation · sector rotation · local sub-agent pool (llama.cpp).</p>
|
| 274 |
-
</div>
|
| 275 |
-
<div class="chips">
|
| 276 |
-
<span>🧠 Local · no cloud APIs</span><span>🦙 llama.cpp</span>
|
| 277 |
-
<span>🤖 4 sub-agents · 1.7B+4B</span><span>📊 Yahoo Finance</span>
|
| 278 |
-
<span>⏰ 18:10 ET</span><span>💾 /data bucket</span><span>🎨 Spectrum 2</span>
|
| 279 |
-
</div>
|
| 280 |
-
</div>""")
|
| 281 |
-
|
| 282 |
-
with gr.Tab("📈 Signals"):
|
| 283 |
-
with gr.Row():
|
| 284 |
-
tickers_in = gr.Textbox(value=", ".join(signal_runner.DEFAULT_POOL),
|
| 285 |
-
label="Ticker pool (comma separated)", scale=4)
|
| 286 |
-
force_cb = gr.Checkbox(value=False, label="Force fresh download", scale=1)
|
| 287 |
-
run_btn = gr.Button("▶ Run analysis", variant="primary", scale=1)
|
| 288 |
-
sig_summary = gr.Markdown(automation.STATE["signals_summary"])
|
| 289 |
-
sig_table = gr.Dataframe(label="Tomorrow's plan — long-hold mode (sorted: BUY → SELL → HOLD → WAIT)",
|
| 290 |
-
interactive=False, wrap=True)
|
| 291 |
-
gr.Markdown("**Stock summary** — pick a ticker for a plain-English raw read, "
|
| 292 |
-
"then let the Summary sub-agent write an AI Summary.",
|
| 293 |
-
elem_classes=["s2-footnote"])
|
| 294 |
-
with gr.Row():
|
| 295 |
-
detail_pick = gr.Dropdown(choices=[], label="Ticker", scale=2)
|
| 296 |
-
explain_btn = gr.Button("🤖 AI summary (local LLM)", scale=1)
|
| 297 |
-
detail_box = gr.Markdown(label="Raw read")
|
| 298 |
-
explain_box = gr.Markdown(elem_classes=["llm-out"])
|
| 299 |
-
with gr.Row():
|
| 300 |
-
sig_email = gr.Textbox(label="Email this summary to", scale=4,
|
| 301 |
-
placeholder="name@example.com (comma-separate for several)")
|
| 302 |
-
sig_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 303 |
-
sig_email_status = gr.Markdown()
|
| 304 |
-
|
| 305 |
-
with gr.Tab("🔄 Sector Rotation"):
|
| 306 |
-
gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500 coverage). "
|
| 307 |
-
"**Flow proxy = price change % × dollar volume**; RS = return minus SPY. "
|
| 308 |
-
"True per-sector fund-flow feeds are paid data — this is the standard free proxy.")
|
| 309 |
-
with gr.Row():
|
| 310 |
-
rot_btn = gr.Button("↻ Refresh rotation (instant)", variant="primary")
|
| 311 |
-
rot_ai_btn = gr.Button("🤖 AI narrative (local LLM)")
|
| 312 |
-
rot_asof = gr.Markdown("Press refresh (or Run analysis on the Signals tab).")
|
| 313 |
-
with gr.Row():
|
| 314 |
-
rot_1d = gr.Dataframe(label="1-Day (today's rotation)", interactive=False)
|
| 315 |
-
with gr.Row():
|
| 316 |
-
rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
|
| 317 |
-
rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
|
| 318 |
-
rot_ai = gr.Markdown(label="AI rotation narrative", elem_classes=["llm-out"])
|
| 319 |
-
with gr.Row():
|
| 320 |
-
rot_email = gr.Textbox(label="Email this narrative to", scale=4,
|
| 321 |
-
placeholder="name@example.com (comma-separate for several)")
|
| 322 |
-
rot_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 323 |
-
rot_email_status = gr.Markdown()
|
| 324 |
-
|
| 325 |
-
with gr.Tab("📰 Watchlist News"):
|
| 326 |
-
gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
|
| 327 |
-
"News found → AI brief is pushed below. No news → the ticker is ignored "
|
| 328 |
-
"(listed under *Quiet today*).")
|
| 329 |
-
with gr.Row():
|
| 330 |
-
hold_in = gr.Textbox(value=", ".join(news_watch.load_holdings()),
|
| 331 |
-
label="My holdings (comma separated)", scale=3)
|
| 332 |
-
save_btn = gr.Button("💾 Save holdings", scale=1)
|
| 333 |
-
news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
|
| 334 |
-
hold_status = gr.Markdown()
|
| 335 |
-
news_out = gr.Markdown(elem_classes=["llm-out"])
|
| 336 |
-
with gr.Row():
|
| 337 |
-
news_email = gr.Textbox(label="Email this news brief to", scale=4,
|
| 338 |
-
placeholder="name@example.com (comma-separate for several)")
|
| 339 |
-
news_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 340 |
-
news_email_status = gr.Markdown()
|
| 341 |
-
|
| 342 |
-
with gr.Tab("🧪 Auto Research"):
|
| 343 |
-
gr.Markdown("**Multi-step research agent** (fully local): PLAN → 5 evidence tools "
|
| 344 |
-
"(fundamentals · quarterly financials · price action · **the Chan engine "
|
| 345 |
-
"itself** · news) → section-by-section analysis → report. Every step is "
|
| 346 |
-
"logged to a JSON **agent trace** on persistent storage. "
|
| 347 |
-
"New tickers entering the signal pool get a report **auto-generated** "
|
| 348 |
-
"by the daily pipeline.")
|
| 349 |
-
with gr.Row():
|
| 350 |
-
res_in = gr.Textbox(label="Ticker", placeholder="e.g. NVDA", scale=3)
|
| 351 |
-
res_btn = gr.Button("🤖 Run research agent", variant="primary", scale=1)
|
| 352 |
-
res_progress = gr.Markdown()
|
| 353 |
-
res_out = gr.Markdown(elem_classes=["llm-out"])
|
| 354 |
-
with gr.Row():
|
| 355 |
-
res_email = gr.Textbox(label="Email this report to", scale=4,
|
| 356 |
-
placeholder="name@example.com (comma-separate for several)")
|
| 357 |
-
res_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 358 |
-
res_email_status = gr.Markdown()
|
| 359 |
-
gr.Markdown("**Report library** (auto + manual, stored on `/data`):",
|
| 360 |
-
elem_classes=["s2-footnote"])
|
| 361 |
-
with gr.Row():
|
| 362 |
-
rep_pick = gr.Dropdown(choices=research_agent.list_reports(),
|
| 363 |
-
label="Saved reports", scale=3)
|
| 364 |
-
rep_open = gr.Button("📂 Open report", scale=1)
|
| 365 |
-
rep_view = gr.Markdown()
|
| 366 |
-
|
| 367 |
-
with gr.Tab("⏰ Automation"):
|
| 368 |
-
gr.Markdown(paths.storage_status())
|
| 369 |
-
auto_md = gr.Markdown(automation.schedule_info())
|
| 370 |
-
with gr.Row():
|
| 371 |
-
auto_now = gr.Button("⚡ Run now", variant="primary")
|
| 372 |
-
auto_msg = gr.Markdown()
|
| 373 |
-
auto_log = gr.Textbox(lines=14, label="Pipeline log (live — updates every 2s)",
|
| 374 |
-
elem_id="detail-log")
|
| 375 |
-
traces_md = gr.Markdown(research_agent.list_traces())
|
| 376 |
-
gr.Markdown("**📡 Share traces** — publish every JSON agent trace in "
|
| 377 |
-
"`/data/traces` as a Hugging Face **dataset** (one click, uses "
|
| 378 |
-
"your `HF_TOKEN` secret — no command line). Earns *Sharing is "
|
| 379 |
-
"Caring*.", elem_classes=["s2-footnote"])
|
| 380 |
-
with gr.Row():
|
| 381 |
-
trace_repo = gr.Textbox(
|
| 382 |
-
value="ranranrunforit/chan-compass-agent-traces",
|
| 383 |
-
label="Dataset repo id", scale=3)
|
| 384 |
-
trace_pub = gr.Button("📡 Publish traces as dataset", scale=1)
|
| 385 |
-
trace_pub_status = gr.Markdown()
|
| 386 |
-
trace_pub.click(_publish_traces, trace_repo, trace_pub_status)
|
| 387 |
-
auto_log_timer = gr.Timer(2.0)
|
| 388 |
-
|
| 389 |
-
def _auto_tick():
|
| 390 |
-
log = "\n".join(automation.STATE["log"][-40:]) or "(no log yet)"
|
| 391 |
-
return log, research_agent.list_traces()
|
| 392 |
-
|
| 393 |
-
auto_log_timer.tick(_auto_tick, None, [auto_log, traces_md])
|
| 394 |
-
|
| 395 |
-
with gr.Tab("🧠 Model"):
|
| 396 |
-
gr.Markdown("All AI runs **locally** through **llama.cpp** (llama-cpp-python) with "
|
| 397 |
-
"Qwen3 GGUF weights — every option is far below the 32B-parameter cap, "
|
| 398 |
-
"and nothing leaves the machine. **First load installs the llama.cpp "
|
| 399 |
-
"runtime + downloads the GGUF (one-time, usually 1–3 min; worst case "
|
| 400 |
-
"~15 min if it has to compile).** Signals/rotation/news never depend on it.")
|
| 401 |
-
gr.Markdown("**Sub-agent pool:** Summary Sub-Agent (Chan-Tuned Qwen3-1.7B, "
|
| 402 |
-
"fixed) handles Explain / rotation narrative / news briefs; "
|
| 403 |
-
"`deep` Analyst writes research reports. Each has its own lock — "
|
| 404 |
-
"they run in parallel. Pick the Analyst model below:")
|
| 405 |
-
model_pick = gr.Radio(choices=list(llm_local.MODEL_ZOO.keys()),
|
| 406 |
-
value=llm_local.DEFAULT_MODEL, label="Analyst (deep) model")
|
| 407 |
-
with gr.Row():
|
| 408 |
-
load_btn = gr.Button("⬇ Load model", variant="primary")
|
| 409 |
-
test_btn = gr.Button("⚡ Test sub-agents now")
|
| 410 |
-
model_status = gr.Markdown(llm_local.status())
|
| 411 |
-
model_test_out = gr.Markdown()
|
| 412 |
-
model_timer = gr.Timer(2.0)
|
| 413 |
-
model_timer.tick(lambda: llm_local.status(), None, model_status)
|
| 414 |
-
autotest_timer = gr.Timer(3.0)
|
| 415 |
-
autotest_timer.tick(_auto_selftest, None, model_test_out)
|
| 416 |
-
|
| 417 |
-
gr.Markdown("---\n### 🎯 Fine-tuning dataset (Well-Tuned badge)\n"
|
| 418 |
-
"Every Signals **AI summary** you run is saved as a "
|
| 419 |
-
"(raw read → narrative) training pair on `/data`. Capture a "
|
| 420 |
-
"few hundred, export the JSONL, then follow `FINETUNE_GUIDE.md` "
|
| 421 |
-
"to LoRA-tune Qwen3-1.7B and publish it.")
|
| 422 |
-
ft_status = gr.Markdown(finetune_data.status_line())
|
| 423 |
-
ft_export = gr.Button("⬇ Export dataset (JSONL)", variant="primary")
|
| 424 |
-
ft_out = gr.Markdown()
|
| 425 |
-
ft_file = gr.File(label="Download training data", visible=False)
|
| 426 |
-
ft_timer = gr.Timer(3.0)
|
| 427 |
-
ft_timer.tick(lambda: finetune_data.status_line(), None, ft_status)
|
| 428 |
-
ft_export.click(_export_dataset, None, [ft_out, ft_file])
|
| 429 |
-
|
| 430 |
-
gr.Markdown("Chan Compass · educational tool, not investment advice · "
|
| 431 |
-
"data: Yahoo Finance · design language: Adobe Spectrum 2",
|
| 432 |
-
elem_classes=["s2-footnote"])
|
| 433 |
-
|
| 434 |
-
# wiring
|
| 435 |
-
run_btn.click(ui_run_signals, [tickers_in, force_cb],
|
| 436 |
-
[sig_table, sig_summary, detail_pick])
|
| 437 |
-
detail_pick.change(ui_show_detail, detail_pick, detail_box)
|
| 438 |
-
explain_btn.click(ui_explain_detail, detail_pick, explain_box)
|
| 439 |
-
rot_btn.click(ui_refresh_rotation, None, [rot_1d, rot_5d, rot_20d, rot_asof, rot_ai])
|
| 440 |
-
rot_ai_btn.click(ui_rotation_ai, None, rot_ai)
|
| 441 |
-
save_btn.click(ui_save_holdings, hold_in, hold_status)
|
| 442 |
-
news_btn.click(ui_check_news, None, news_out)
|
| 443 |
-
res_btn.click(ui_research, res_in, [res_progress, res_out, rep_pick])
|
| 444 |
-
sig_email_btn.click(lambda body, to: emailer.send_result(body, to, "Signals summary"),
|
| 445 |
-
[explain_box, sig_email], sig_email_status)
|
| 446 |
-
rot_email_btn.click(lambda body, to: emailer.send_result(body, to, "Sector rotation"),
|
| 447 |
-
[rot_ai, rot_email], rot_email_status)
|
| 448 |
-
news_email_btn.click(lambda body, to: emailer.send_result(body, to, "Watchlist news"),
|
| 449 |
-
[news_out, news_email], news_email_status)
|
| 450 |
-
res_email_btn.click(lambda body, to: emailer.send_result(body, to, "Research report"),
|
| 451 |
-
[res_out, res_email], res_email_status)
|
| 452 |
-
rep_open.click(ui_open_report, rep_pick, rep_view)
|
| 453 |
-
auto_now.click(lambda: automation.run_pipeline(force=True), None, auto_msg)
|
| 454 |
-
load_btn.click(ui_load_model, model_pick, model_status)
|
| 455 |
-
test_btn.click(_manual_selftest, None, model_test_out)
|
| 456 |
-
|
| 457 |
-
automation.start_scheduler()
|
| 458 |
-
|
| 459 |
-
# Auto-load the default local model in the background so AI features (English
|
| 460 |
-
# explanations, rotation narrative, research agent) are ready without a click.
|
| 461 |
-
def _auto_load_model():
|
| 462 |
-
try:
|
| 463 |
-
automation._log("Auto-loading sub-agents (llama.cpp)…")
|
| 464 |
-
llm_local.auto_load_all()
|
| 465 |
-
except Exception as e:
|
| 466 |
-
automation._log(f"Sub-agent auto-load failed: {e}")
|
| 467 |
-
|
| 468 |
-
try:
|
| 469 |
-
import paths as _paths
|
| 470 |
-
automation._log(_paths.storage_status())
|
| 471 |
-
except Exception:
|
| 472 |
-
pass
|
| 473 |
-
if os.environ.get("AUTO_LOAD_MODEL", "1") == "1":
|
| 474 |
-
threading.Thread(target=_auto_load_model, daemon=True).start()
|
| 475 |
-
|
| 476 |
-
if __name__ == "__main__":
|
| 477 |
-
import paths as _p
|
| 478 |
-
_allowed = [os.path.join(os.getcwd(), "exports"), _p.DATASET_DIR]
|
| 479 |
-
os.makedirs(_allowed[0], exist_ok=True)
|
| 480 |
-
if _GR_MAJOR >= 6:
|
| 481 |
-
demo.launch(theme=theme, css=S2_CSS, allowed_paths=_allowed)
|
| 482 |
-
else:
|
| 483 |
-
demo.launch(allowed_paths=_allowed)
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
SPACING & SIZING — Chan Compass · Spectrum 2
|
| 3 |
+
Spectrum 2 spacing scale (px): 0 2 4 8 12 16 20 24 28 32 40 48 64 80 96.
|
| 4 |
+
8px is the base rhythm. Padding uses px; layout gaps reuse the same scale.
|
| 5 |
+
============================================================ */
|
| 6 |
+
|
| 7 |
+
:root {
|
| 8 |
+
--space-0: 0;
|
| 9 |
+
--space-50: 2px;
|
| 10 |
+
--space-75: 4px;
|
| 11 |
+
--space-100: 8px; /* base unit */
|
| 12 |
+
--space-150: 12px;
|
| 13 |
+
--space-200: 16px;
|
| 14 |
+
--space-250: 20px;
|
| 15 |
+
--space-300: 24px;
|
| 16 |
+
--space-400: 32px;
|
| 17 |
+
--space-500: 40px;
|
| 18 |
+
--space-600: 48px;
|
| 19 |
+
--space-700: 64px;
|
| 20 |
+
--space-800: 80px;
|
| 21 |
+
--space-900: 96px;
|
| 22 |
+
|
| 23 |
+
/* ---- Corner radii (Spectrum 2) ---- */
|
| 24 |
+
--radius-none: 0;
|
| 25 |
+
--radius-sm: 4px; /* chips, small controls */
|
| 26 |
+
--radius-default: 8px; /* inputs, buttons (square)*/
|
| 27 |
+
--radius-lg: 10px; /* nested cards */
|
| 28 |
+
--radius-xl: 16px; /* cards, panels */
|
| 29 |
+
--radius-2xl: 20px; /* hero, large surfaces */
|
| 30 |
+
--radius-full: 9999px; /* pills, avatars */
|
| 31 |
+
|
| 32 |
+
/* ---- Border widths ---- */
|
| 33 |
+
--border-width-100: 1px;
|
| 34 |
+
--border-width-200: 2px;
|
| 35 |
+
--border-width-400: 4px;
|
| 36 |
+
|
| 37 |
+
/* ---- Control heights (medium scale) ---- */
|
| 38 |
+
--control-height-sm: 28px;
|
| 39 |
+
--control-height-md: 32px;
|
| 40 |
+
--control-height-lg: 40px;
|
| 41 |
+
|
| 42 |
+
/* ---- Layout ---- */
|
| 43 |
+
--container-max: 1280px;
|
| 44 |
+
--field-radius: var(--radius-default);
|
| 45 |
+
--card-radius: var(--radius-xl);
|
| 46 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
automation.py
CHANGED
|
@@ -1,147 +1,61 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
-
_lock = threading.Lock()
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def _log(msg: str):
|
| 46 |
-
stamp = dt.datetime.now(NY).strftime("%m-%d %H:%M:%S ET")
|
| 47 |
-
STATE["log"] = (STATE["log"] + [f"[{stamp}] {msg}"])[-60:]
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
def run_pipeline(tickers=None, force: bool = True) -> str:
|
| 51 |
-
"""Full daily refresh. Safe to call from the UI or the scheduler."""
|
| 52 |
-
import news_watch
|
| 53 |
-
import rotation
|
| 54 |
-
import signal_runner
|
| 55 |
-
|
| 56 |
-
with _lock:
|
| 57 |
-
if STATE["running"]:
|
| 58 |
-
return "A pipeline run is already in progress."
|
| 59 |
-
STATE["running"] = True
|
| 60 |
-
try:
|
| 61 |
-
_log("Pipeline start: refreshing data + signals…")
|
| 62 |
-
df, details, summary, errors = signal_runner.run_signals(tickers, force=force)
|
| 63 |
-
STATE["signals_df"] = df
|
| 64 |
-
STATE["signals_details"] = details
|
| 65 |
-
STATE["signals_summary"] = summary
|
| 66 |
-
for e in errors:
|
| 67 |
-
_log(f"signal skip: {e}")
|
| 68 |
-
_log(f"Signals done: {summary}")
|
| 69 |
-
|
| 70 |
-
d1, d5, d20, asof = rotation.build_rotation(force=force)
|
| 71 |
-
STATE["rotation"] = (d1, d5, d20, asof)
|
| 72 |
-
# LLM narrative is generated ON DEMAND from the Rotation tab button —
|
| 73 |
-
# the pipeline itself never waits on the model.
|
| 74 |
-
STATE["rotation_narrative"] = rotation.rotation_brief(d1, d5, d20)
|
| 75 |
-
_log(f"Sector rotation rebuilt (as of {asof}).")
|
| 76 |
-
|
| 77 |
-
STATE["news_md"] = news_watch.check_holdings_news()
|
| 78 |
-
_log("Holdings news checked.")
|
| 79 |
-
|
| 80 |
-
# ── Feature 4: auto research report for every NEW ticker in the pool ──
|
| 81 |
-
try:
|
| 82 |
-
import json
|
| 83 |
-
import os
|
| 84 |
-
import paths
|
| 85 |
-
import research_agent
|
| 86 |
-
known_path = os.path.join(paths.OUTPUT_DIR, "known_tickers.json")
|
| 87 |
-
try:
|
| 88 |
-
with open(known_path, encoding="utf-8") as f:
|
| 89 |
-
known = set(json.load(f))
|
| 90 |
-
except (OSError, ValueError):
|
| 91 |
-
known = set()
|
| 92 |
-
current = set(df["Ticker"].tolist()) if df is not None and len(df) else set()
|
| 93 |
-
new_tickers = sorted(current - known)
|
| 94 |
-
generated = set()
|
| 95 |
-
for t in new_tickers[:5]: # safety cap per run
|
| 96 |
-
_log(f"New ticker {t} → auto-generating research report…")
|
| 97 |
-
report, trace = research_agent.run_research(t, auto=True)
|
| 98 |
-
if report:
|
| 99 |
-
generated.add(t)
|
| 100 |
-
_log(f"Report for {t} done{' (+trace)' if trace else ''}.")
|
| 101 |
-
else:
|
| 102 |
-
_log(f"Report for {t} postponed (sub-agents still loading) — "
|
| 103 |
-
f"will retry on the next run.")
|
| 104 |
-
done_set = known | (current - (set(new_tickers) - generated))
|
| 105 |
-
if current:
|
| 106 |
-
with open(known_path, "w", encoding="utf-8") as f:
|
| 107 |
-
json.dump(sorted(done_set), f)
|
| 108 |
-
except Exception as e:
|
| 109 |
-
_log(f"Auto-research skipped: {e}")
|
| 110 |
-
|
| 111 |
-
STATE["last_run"] = dt.datetime.now(NY)
|
| 112 |
-
_log("Pipeline finished.")
|
| 113 |
-
return f"Done. {summary}"
|
| 114 |
-
except Exception as e:
|
| 115 |
-
traceback.print_exc()
|
| 116 |
-
_log(f"Pipeline error: {e}")
|
| 117 |
-
return f"Pipeline error: {e}"
|
| 118 |
-
finally:
|
| 119 |
-
STATE["running"] = False
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def start_scheduler():
|
| 123 |
-
"""Cron: Mon–Fri 18:10 America/New_York."""
|
| 124 |
-
try:
|
| 125 |
-
from apscheduler.schedulers.background import BackgroundScheduler
|
| 126 |
-
from apscheduler.triggers.cron import CronTrigger
|
| 127 |
-
except Exception as e:
|
| 128 |
-
_log(f"APScheduler unavailable: {e}")
|
| 129 |
-
return None
|
| 130 |
-
sched = BackgroundScheduler(timezone=NY)
|
| 131 |
-
sched.add_job(run_pipeline, CronTrigger(day_of_week="mon-fri",
|
| 132 |
-
hour=RUN_HOUR, minute=RUN_MINUTE),
|
| 133 |
-
id="daily_pipeline", max_instances=1, coalesce=True)
|
| 134 |
-
sched.start()
|
| 135 |
-
_log(f"Scheduler armed: Mon–Fri {RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York.")
|
| 136 |
-
return sched
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def schedule_info() -> str:
|
| 140 |
-
now = dt.datetime.now(NY)
|
| 141 |
-
last = STATE["last_run"].strftime("%Y-%m-%d %H:%M ET") if STATE["last_run"] else "never"
|
| 142 |
-
return (f"**Schedule:** Mon–Fri **{RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York** "
|
| 143 |
-
f"(market closes 16:00 ET; by 18:10 the official daily bar has settled — "
|
| 144 |
-
f"that's 07:10 next morning Beijing time).\n\n"
|
| 145 |
-
f"**Now (ET):** {now.strftime('%Y-%m-%d %H:%M')} · **Last run:** {last}\n\n"
|
| 146 |
-
f"⚠️ On free Space hardware the app sleeps when idle and the timer can't fire; "
|
| 147 |
-
f"use **Run now**, or upgrade to always-on hardware for unattended updates.")
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
TYPOGRAPHY — Chan Compass · Spectrum 2
|
| 3 |
+
Spectrum 2 desktop UI base = 14px, modular scale ratio 1.125.
|
| 4 |
+
Three families: sans (UI/headings), serif (editorial), code (mono).
|
| 5 |
+
============================================================ */
|
| 6 |
+
|
| 7 |
+
:root {
|
| 8 |
+
/* ---- Families ---- */
|
| 9 |
+
--font-sans: 'Source Sans 3', 'Adobe Clean', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
| 10 |
+
--font-serif: 'Source Serif 4', 'Adobe Clean Serif', Georgia, 'Times New Roman', serif;
|
| 11 |
+
--font-code: 'Source Code Pro', ui-monospace, 'SF Mono', Menlo, Monaco, monospace;
|
| 12 |
+
|
| 13 |
+
/* ---- Type scale (ratio 1.125, base 14px) ---- */
|
| 14 |
+
--font-size-50: 11px;
|
| 15 |
+
--font-size-75: 12px;
|
| 16 |
+
--font-size-100: 14px; /* UI base */
|
| 17 |
+
--font-size-200: 16px;
|
| 18 |
+
--font-size-300: 18px;
|
| 19 |
+
--font-size-400: 20px;
|
| 20 |
+
--font-size-500: 22px;
|
| 21 |
+
--font-size-600: 25px;
|
| 22 |
+
--font-size-700: 28px;
|
| 23 |
+
--font-size-800: 32px;
|
| 24 |
+
--font-size-900: 36px;
|
| 25 |
+
--font-size-1000: 40px;
|
| 26 |
+
--font-size-1100: 45px;
|
| 27 |
+
--font-size-1200: 50px;
|
| 28 |
+
|
| 29 |
+
/* ---- Weights (Source Sans 3 axis) ---- */
|
| 30 |
+
--font-weight-regular: 400;
|
| 31 |
+
--font-weight-medium: 500;
|
| 32 |
+
--font-weight-semibold: 600;
|
| 33 |
+
--font-weight-bold: 700;
|
| 34 |
+
--font-weight-black: 800;
|
| 35 |
+
|
| 36 |
+
/* ---- Line heights ---- */
|
| 37 |
+
--line-height-ui: 1.3; /* UI components, tight */
|
| 38 |
+
--line-height-heading: 1.15; /* large display headings */
|
| 39 |
+
--line-height-body: 1.5; /* paragraphs, spacious */
|
| 40 |
+
--line-height-code: 1.45;
|
| 41 |
+
|
| 42 |
+
/* ---- Letter spacing ---- */
|
| 43 |
+
--letter-spacing-heading: -0.015em; /* Spectrum tightens large type */
|
| 44 |
+
--letter-spacing-ui: 0;
|
| 45 |
+
--letter-spacing-caps: 0.06em; /* eyebrows / overlines */
|
| 46 |
+
|
| 47 |
+
/* ============================================================
|
| 48 |
+
SEMANTIC TYPE ROLES
|
| 49 |
+
============================================================ */
|
| 50 |
+
--type-display: var(--font-weight-bold) var(--font-size-1000)/var(--line-height-heading) var(--font-sans);
|
| 51 |
+
--type-h1: var(--font-weight-bold) var(--font-size-800)/var(--line-height-heading) var(--font-sans);
|
| 52 |
+
--type-h2: var(--font-weight-bold) var(--font-size-600)/var(--line-height-heading) var(--font-sans);
|
| 53 |
+
--type-h3: var(--font-weight-semibold) var(--font-size-400)/var(--line-height-ui) var(--font-sans);
|
| 54 |
+
--type-h4: var(--font-weight-semibold) var(--font-size-300)/var(--line-height-ui) var(--font-sans);
|
| 55 |
+
--type-body-lg: var(--font-weight-regular) var(--font-size-300)/var(--line-height-body) var(--font-sans);
|
| 56 |
+
--type-body: var(--font-weight-regular) var(--font-size-100)/var(--line-height-body) var(--font-sans);
|
| 57 |
+
--type-ui: var(--font-weight-regular) var(--font-size-100)/var(--line-height-ui) var(--font-sans);
|
| 58 |
+
--type-ui-bold: var(--font-weight-semibold) var(--font-size-100)/var(--line-height-ui) var(--font-sans);
|
| 59 |
+
--type-detail: var(--font-weight-regular) var(--font-size-75)/var(--line-height-ui) var(--font-sans);
|
| 60 |
+
--type-code: var(--font-weight-regular) var(--font-size-75)/var(--line-height-code) var(--font-code);
|
| 61 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
base.css
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
Chan Compass · Spectrum 2 Design System
|
| 3 |
+
Global entry point — consumers link THIS one file.
|
| 4 |
+
Import order: fonts → primitives → semantics. Keep this file
|
| 5 |
+
as @import lines only.
|
| 6 |
+
============================================================ */
|
| 7 |
+
|
| 8 |
+
@import url('tokens/fonts.css');
|
| 9 |
+
@import url('tokens/colors.css');
|
| 10 |
+
@import url('tokens/typography.css');
|
| 11 |
+
@import url('tokens/spacing.css');
|
| 12 |
+
@import url('tokens/elevation.css');
|
| 13 |
+
@import url('tokens/base.css');
|
chan_engine.py
CHANGED
|
@@ -1,1451 +1,514 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 446 |
-
|
| 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 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
| 907 |
-
|
| 908 |
-
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
|
| 912 |
-
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
s1_price, s1_date_str = self._find_prev_s1()
|
| 953 |
-
if s1_price is None or not self.pivots:
|
| 954 |
-
return (None, '')
|
| 955 |
-
s1_date = pd.Timestamp(s1_date_str)
|
| 956 |
-
last_piv = self.pivots[-1]
|
| 957 |
-
right_bi_idx = last_piv.bis[-1] + 1
|
| 958 |
-
for b in self.bis[:right_bi_idx]:
|
| 959 |
-
if b.direction != 'up':
|
| 960 |
-
continue
|
| 961 |
-
if b.end.date <= s1_date:
|
| 962 |
-
continue
|
| 963 |
-
if b.end.price < s1_price - 1e-9:
|
| 964 |
-
return (b.end.price, self._ds(b.end.date))
|
| 965 |
-
return (None, '')
|
| 966 |
-
|
| 967 |
-
def detect_b1(self) -> Optional[Signal]:
|
| 968 |
-
if self.n_bis < 5:
|
| 969 |
-
return None
|
| 970 |
-
cur = self.bis[-1]
|
| 971 |
-
if cur.direction != 'down':
|
| 972 |
-
return None
|
| 973 |
-
dif_now = float(self.dif.iloc[-1])
|
| 974 |
-
if dif_now >= 0:
|
| 975 |
-
return None
|
| 976 |
-
trend_ok = (self.n_pivots >= 2 and self.trend == 'down_trend')
|
| 977 |
-
consol_ok = (self.CFG.get('b1_allow_consol_diverge') and self.n_pivots >= 1)
|
| 978 |
-
if not (trend_ok or consol_ok):
|
| 979 |
-
return None
|
| 980 |
-
abc = self._validate_abc('down')
|
| 981 |
-
if abc is None and consol_ok:
|
| 982 |
-
abc = self._validate_abc_consol('down')
|
| 983 |
-
if abc is None:
|
| 984 |
-
return None
|
| 985 |
-
c_ok, c_low = self._check_c_new_extreme(abc['c_start_idx'], 'down')
|
| 986 |
-
if not c_ok:
|
| 987 |
-
return None
|
| 988 |
-
last_piv = self.pivots[-1]
|
| 989 |
-
a_down = [self.bis[k] for k in range(abc['a_start_idx'], abc['a_end_idx'])
|
| 990 |
-
if self.bis[k].direction == 'down']
|
| 991 |
-
c_down = [self.bis[k] for k in range(abc['c_start_idx'], self.n_bis)
|
| 992 |
-
if self.bis[k].direction == 'down']
|
| 993 |
-
if not a_down or not c_down:
|
| 994 |
-
return None
|
| 995 |
-
dg = self.assess_divergence(a_down[0].start.date, a_down[-1].end.date,
|
| 996 |
-
c_down[0].start.date, c_down[-1].end.date, 'down')
|
| 997 |
-
if dg.grade == 'NONE':
|
| 998 |
-
return None
|
| 999 |
-
div_kind = '趋势底背驰' if dg.is_trend_divergence else '盘整底背驰(第27课)'
|
| 1000 |
-
b_zero = self._b_returns_to_zero(last_piv)
|
| 1001 |
-
dbl_pull = self.detect_double_pullback_to_zero()
|
| 1002 |
-
pos_strength = self.divergence_strength_by_position()
|
| 1003 |
-
post_evo = self.classify_post_divergence('down')
|
| 1004 |
-
a_low_bi = min(a_down, key=lambda b: b.end.price)
|
| 1005 |
-
c_low_bi = min(c_down, key=lambda b: b.end.price)
|
| 1006 |
-
return Signal(kind='B1', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]),
|
| 1007 |
-
reason=f'{div_kind}一买|{self.n_pivots}中枢|ABC三段{"+B回0轴" if b_zero else ""}|{dg.reason}|DIF={dif_now:.4f}<0',
|
| 1008 |
-
pivot_zg=last_piv.zg, pivot_zd=last_piv.zd, macd_ratio=dg.area_ratio, dif_value=dif_now,
|
| 1009 |
-
n_pivots=self.n_pivots, trend=self.trend,
|
| 1010 |
-
extras={'a_seg': (a_down[0].start.date, a_down[-1].end.date, a_down[0].start.price, a_down[-1].end.price),
|
| 1011 |
-
'diverge_grade': dg.grade,
|
| 1012 |
-
'a_low': float(a_low_bi.end.price),
|
| 1013 |
-
'a_low_date': self._ds(a_low_bi.end.date),
|
| 1014 |
-
'b1_price': float(cur.end.price),
|
| 1015 |
-
'b1_date': self._ds(cur.end.date),
|
| 1016 |
-
'macd_grade': dg.grade,
|
| 1017 |
-
'macd_area_ratio': dg.area_ratio,
|
| 1018 |
-
'dif_ok': dg.dif_ok,
|
| 1019 |
-
'area_ok': dg.area_ok,
|
| 1020 |
-
'b_returns_zero': b_zero,
|
| 1021 |
-
'double_pullback': dbl_pull,
|
| 1022 |
-
'pos_strength': pos_strength,
|
| 1023 |
-
'post_evolution': post_evo['evolution'],
|
| 1024 |
-
'c_new_low': c_low,
|
| 1025 |
-
'c_new_low_date': self._ds(c_low_bi.end.date),
|
| 1026 |
-
'n_trend_pivots': self.n_trend_pivots,
|
| 1027 |
-
'price_date': self._ds(cur.end.date),
|
| 1028 |
-
'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
|
| 1029 |
-
'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
|
| 1030 |
-
'pivot_start_date': self._ds(last_piv.start_date),
|
| 1031 |
-
'pivot_end_date': self._ds(last_piv.end_date)},
|
| 1032 |
-
diverge_grade=dg)
|
| 1033 |
-
|
| 1034 |
-
def detect_b2(self) -> Optional[Signal]:
|
| 1035 |
-
if self.n_bis < 4:
|
| 1036 |
-
return None
|
| 1037 |
-
cur = self.bis[-1]
|
| 1038 |
-
if cur.direction != 'down':
|
| 1039 |
-
return None
|
| 1040 |
-
prev_downs = [b for b in self.bis[:-1] if b.direction == 'down']
|
| 1041 |
-
if not prev_downs:
|
| 1042 |
-
return None
|
| 1043 |
-
prev = prev_downs[-1]
|
| 1044 |
-
if not (cur.low >= prev.low and cur.end.price > prev.end.price):
|
| 1045 |
-
return None
|
| 1046 |
-
cur_price = float(self.close.iloc[-1])
|
| 1047 |
-
if cur_price < cur.end.price * (1 - self.PIVOT_TOLERANCE):
|
| 1048 |
-
return None
|
| 1049 |
-
if self.CFG.get('b2s2_anchor_to_first'):
|
| 1050 |
-
b1_anchor, _ = self._find_prev_b1()
|
| 1051 |
-
if b1_anchor is None:
|
| 1052 |
-
return None
|
| 1053 |
-
if cur.end.price < b1_anchor - 1e-9:
|
| 1054 |
-
return None
|
| 1055 |
-
dif_now = float(self.dif.iloc[-1])
|
| 1056 |
-
if self.CFG.get('b2_macd_zero_pullback'):
|
| 1057 |
-
look = self.dif.tail(12)
|
| 1058 |
-
crossed_up = bool((look > 0).any())
|
| 1059 |
-
if not crossed_up:
|
| 1060 |
-
return None
|
| 1061 |
-
if dif_now < -self.DIF_TOLERANCE:
|
| 1062 |
-
return None
|
| 1063 |
-
last_piv = self.pivots[-1] if self.pivots else None
|
| 1064 |
-
b1_price, b1_date = prev.end.price, self._ds(prev.end.date)
|
| 1065 |
-
return Signal(kind='B2', date=self.df_raw['date'].iloc[-1], price=cur_price,
|
| 1066 |
-
reason=f'二买:一买后回踩不破|当前低{cur.end.price:.3f}>一买{b1_price:.3f}|二买不以背驰为成立条件(第21课)',
|
| 1067 |
-
pivot_zg=None, pivot_zd=None,
|
| 1068 |
-
macd_ratio=None, dif_value=dif_now, n_pivots=self.n_pivots, trend=self.trend,
|
| 1069 |
-
extras={'prev_low': prev.end.price, 'cur_low': cur.end.price,
|
| 1070 |
-
'cur_low_date': self._ds(cur.end.date),
|
| 1071 |
-
'prev_low_date': self._ds(prev.end.date),
|
| 1072 |
-
'b1_price': b1_price,
|
| 1073 |
-
'b1_date': b1_date,
|
| 1074 |
-
'price_date': self._ds(cur.end.date),
|
| 1075 |
-
'context_pivot_zg': last_piv.zg if last_piv else None,
|
| 1076 |
-
'context_pivot_zd': last_piv.zd if last_piv else None,
|
| 1077 |
-
'context_pivot_zg_date': self._ds(last_piv.zg_date) if last_piv and last_piv.zg_date else '',
|
| 1078 |
-
'context_pivot_zd_date': self._ds(last_piv.zd_date) if last_piv and last_piv.zd_date else '',
|
| 1079 |
-
'context_pivot_start_date': self._ds(last_piv.start_date) if last_piv else '',
|
| 1080 |
-
'context_pivot_end_date': self._ds(last_piv.end_date) if last_piv else ''},
|
| 1081 |
-
diverge_grade=None)
|
| 1082 |
-
|
| 1083 |
-
def detect_b3(self) -> Optional[Signal]:
|
| 1084 |
-
if self.n_bis < 5 or self.n_pivots < 1:
|
| 1085 |
-
return None
|
| 1086 |
-
late_trend_b3 = self.n_trend_pivots >= 2
|
| 1087 |
-
last_piv = self.pivots[-1]
|
| 1088 |
-
zg, zd = last_piv.zg, last_piv.zd
|
| 1089 |
-
piv_height = zg - zd
|
| 1090 |
-
cur = self.bis[-1]
|
| 1091 |
-
if cur.direction != 'up':
|
| 1092 |
-
return None
|
| 1093 |
-
pair = self._last_exit_pullback_segments(last_piv, 'up', 'down')
|
| 1094 |
-
if pair is None:
|
| 1095 |
-
return None
|
| 1096 |
-
exit_seg, pull_seg = pair
|
| 1097 |
-
if not (exit_seg.low <= zg * (1 + self.PIVOT_TOLERANCE) and exit_seg.high > zg):
|
| 1098 |
-
return None
|
| 1099 |
-
if pull_seg.low < zg * (1 - self.PIVOT_TOLERANCE):
|
| 1100 |
-
return None
|
| 1101 |
-
leaves_pivot = pull_seg.low >= zg
|
| 1102 |
-
cur_price = float(self.close.iloc[-1])
|
| 1103 |
-
if cur_price <= zg:
|
| 1104 |
-
return None
|
| 1105 |
-
ex_amp = exit_seg.high - exit_seg.low
|
| 1106 |
-
if piv_height > 0 and ex_amp < piv_height * 0.5:
|
| 1107 |
-
return None
|
| 1108 |
-
if len(last_piv.bis) < 3:
|
| 1109 |
-
return None
|
| 1110 |
-
confirm_txt = '回踩离枢确认(新中枢生成)' if leaves_pivot else '回踩贴ZG(容差内,新中枢待确认)'
|
| 1111 |
-
b1_price, b1_date = self._find_prev_b1()
|
| 1112 |
-
b2_price, b2_date = self._find_prev_b2()
|
| 1113 |
-
warn_txt = '|第二个以上同向中枢,实盘宜改用低级别一买' if late_trend_b3 else ''
|
| 1114 |
-
return Signal(kind='B3', date=self.df_raw['date'].iloc[-1], price=cur_price,
|
| 1115 |
-
reason=f'标准三买|ZG={zg:.3f},ZD={zd:.3f}|线段离枢:{exit_seg.low:.3f}→{exit_seg.high:.3f}(幅度{ex_amp:.3f})|线段回试低{pull_seg.low:.3f}|{confirm_txt}|当前{cur_price:.3f}>ZG{warn_txt}',
|
| 1116 |
-
pivot_zg=zg, pivot_zd=zd, macd_ratio=None, dif_value=float(self.dif.iloc[-1]),
|
| 1117 |
-
n_pivots=self.n_pivots, trend=self.trend,
|
| 1118 |
-
extras={'exit_seg': (exit_seg.low, exit_seg.high),
|
| 1119 |
-
'pull_low': pull_seg.low,
|
| 1120 |
-
'pull_low_date': self._ds(pull_seg.end.date),
|
| 1121 |
-
'exit_start_date': self._ds(exit_seg.start.date),
|
| 1122 |
-
'exit_end_date': self._ds(exit_seg.end.date),
|
| 1123 |
-
'b1_price': b1_price,
|
| 1124 |
-
'b1_date': b1_date,
|
| 1125 |
-
'b2_price': b2_price,
|
| 1126 |
-
'b2_date': b2_date,
|
| 1127 |
-
'piv_bi_count': len(last_piv.bis), 'leaves_pivot': leaves_pivot,
|
| 1128 |
-
'late_trend_b3': late_trend_b3,
|
| 1129 |
-
'price_date': self._ds(self.df_raw['date'].iloc[-1]),
|
| 1130 |
-
'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
|
| 1131 |
-
'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
|
| 1132 |
-
'pivot_start_date': self._ds(last_piv.start_date),
|
| 1133 |
-
'pivot_end_date': self._ds(last_piv.end_date)},
|
| 1134 |
-
diverge_grade=None)
|
| 1135 |
-
|
| 1136 |
-
def detect_s1(self) -> Optional[Signal]:
|
| 1137 |
-
if self.n_bis < 5:
|
| 1138 |
-
return None
|
| 1139 |
-
cur = self.bis[-1]
|
| 1140 |
-
if cur.direction != 'up':
|
| 1141 |
-
return None
|
| 1142 |
-
trend_ok = (self.n_pivots >= 2 and self.trend == 'up_trend')
|
| 1143 |
-
consol_ok = (self.CFG.get('b1_allow_consol_diverge') and self.n_pivots >= 1)
|
| 1144 |
-
if not (trend_ok or consol_ok):
|
| 1145 |
-
return None
|
| 1146 |
-
abc = self._validate_abc('up')
|
| 1147 |
-
if abc is None and consol_ok:
|
| 1148 |
-
abc = self._validate_abc_consol('up')
|
| 1149 |
-
if abc is None:
|
| 1150 |
-
return None
|
| 1151 |
-
last_piv = self.pivots[-1]
|
| 1152 |
-
a_start_idx = abc['a_start_idx']; a_end_idx = abc['a_end_idx']
|
| 1153 |
-
if a_end_idx <= a_start_idx:
|
| 1154 |
-
return None
|
| 1155 |
-
a_up_bis = [self.bis[k] for k in range(a_start_idx, a_end_idx) if self.bis[k].direction == 'up']
|
| 1156 |
-
if not a_up_bis:
|
| 1157 |
-
return None
|
| 1158 |
-
a_high = max(b.end.price for b in a_up_bis)
|
| 1159 |
-
c_start_idx = last_piv.bis[-1] + 1
|
| 1160 |
-
c_up_bis = [self.bis[k] for k in range(c_start_idx, self.n_bis) if self.bis[k].direction == 'up']
|
| 1161 |
-
if not c_up_bis:
|
| 1162 |
-
return None
|
| 1163 |
-
c_high = max(b.end.price for b in c_up_bis)
|
| 1164 |
-
if c_high <= a_high:
|
| 1165 |
-
return None
|
| 1166 |
-
a_high_bi = max(a_up_bis, key=lambda b: b.end.price)
|
| 1167 |
-
dg = self.assess_divergence(a_up_bis[0].start.date, a_up_bis[-1].end.date,
|
| 1168 |
-
c_up_bis[0].start.date, c_up_bis[-1].end.date, 'up')
|
| 1169 |
-
if dg.grade == 'NONE':
|
| 1170 |
-
return None
|
| 1171 |
-
b_zero = self._b_returns_to_zero(last_piv)
|
| 1172 |
-
dbl_pull = self.detect_double_pullback_to_zero()
|
| 1173 |
-
pos_strength = self.divergence_strength_by_position()
|
| 1174 |
-
post_evo = self.classify_post_divergence('up')
|
| 1175 |
-
dif_now = float(self.dif.iloc[-1])
|
| 1176 |
-
c_high_bi = max(c_up_bis, key=lambda b: b.end.price)
|
| 1177 |
-
return Signal(kind='S1', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]),
|
| 1178 |
-
reason=f'一卖|上涨趋势{self.n_pivots}中枢|价创新高C{c_high:.3f}>A段高{a_high:.3f}{"+B回0轴" if b_zero else ""}|{dg.reason}',
|
| 1179 |
-
pivot_zg=last_piv.zg, pivot_zd=last_piv.zd, macd_ratio=dg.area_ratio, dif_value=dif_now,
|
| 1180 |
-
n_pivots=self.n_pivots, trend=self.trend,
|
| 1181 |
-
extras={'a_high': a_high, 'c_high': c_high, 'a_area': dg.a_area, 'c_area': dg.c_area,
|
| 1182 |
-
'diverge_grade': dg.grade,
|
| 1183 |
-
'a_high_date': self._ds(a_high_bi.end.date),
|
| 1184 |
-
'b_returns_zero': b_zero,
|
| 1185 |
-
'double_pullback': dbl_pull,
|
| 1186 |
-
'pos_strength': pos_strength,
|
| 1187 |
-
'post_evolution': post_evo['evolution'],
|
| 1188 |
-
'c_high_date': self._ds(c_high_bi.end.date),
|
| 1189 |
-
'price_date': self._ds(cur.end.date),
|
| 1190 |
-
'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
|
| 1191 |
-
'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
|
| 1192 |
-
'pivot_start_date': self._ds(last_piv.start_date),
|
| 1193 |
-
'pivot_end_date': self._ds(last_piv.end_date)},
|
| 1194 |
-
diverge_grade=dg)
|
| 1195 |
-
|
| 1196 |
-
def detect_s2(self) -> Optional[Signal]:
|
| 1197 |
-
if self.n_bis < 4:
|
| 1198 |
-
return None
|
| 1199 |
-
cur = self.bis[-1]
|
| 1200 |
-
if cur.direction != 'up':
|
| 1201 |
-
return None
|
| 1202 |
-
prev_ups = [b for b in self.bis[:-1] if b.direction == 'up']
|
| 1203 |
-
if not prev_ups:
|
| 1204 |
-
return None
|
| 1205 |
-
prev = prev_ups[-1]
|
| 1206 |
-
if cur.high < prev.high and cur.end.price < prev.end.price:
|
| 1207 |
-
cur_price = float(self.close.iloc[-1])
|
| 1208 |
-
if cur_price > cur.end.price * (1 + self.PIVOT_TOLERANCE):
|
| 1209 |
-
return None
|
| 1210 |
-
if self.CFG.get('b2s2_anchor_to_first'):
|
| 1211 |
-
s1_anchor, _ = self._find_prev_s1()
|
| 1212 |
-
if s1_anchor is None:
|
| 1213 |
-
return None
|
| 1214 |
-
if cur.end.price > s1_anchor + 1e-9:
|
| 1215 |
-
return None
|
| 1216 |
-
dif_now = float(self.dif.iloc[-1])
|
| 1217 |
-
last_piv = self.pivots[-1] if self.pivots else None
|
| 1218 |
-
s1_price, s1_date = prev.end.price, self._ds(prev.end.date)
|
| 1219 |
-
return Signal(kind='S2', date=self.df_raw['date'].iloc[-1], price=cur_price,
|
| 1220 |
-
reason=f'二卖:一卖后反弹不破|当前高{cur.end.price:.3f}<一卖{s1_price:.3f}|二卖不以背驰为成立条件(第21课)',
|
| 1221 |
-
pivot_zg=None, pivot_zd=None,
|
| 1222 |
-
dif_value=dif_now, n_pivots=self.n_pivots, trend=self.trend,
|
| 1223 |
-
extras={'prev_high': prev.end.price, 'cur_high': cur.end.price,
|
| 1224 |
-
'cur_high_date': self._ds(cur.end.date),
|
| 1225 |
-
'prev_high_date': self._ds(prev.end.date),
|
| 1226 |
-
's1_price': s1_price, 's1_date': s1_date,
|
| 1227 |
-
'price_date': self._ds(cur.end.date),
|
| 1228 |
-
'context_pivot_zg': last_piv.zg if last_piv else None,
|
| 1229 |
-
'context_pivot_zd': last_piv.zd if last_piv else None,
|
| 1230 |
-
'context_pivot_zg_date': self._ds(last_piv.zg_date) if last_piv and last_piv.zg_date else '',
|
| 1231 |
-
'context_pivot_zd_date': self._ds(last_piv.zd_date) if last_piv and last_piv.zd_date else '',
|
| 1232 |
-
'context_pivot_start_date': self._ds(last_piv.start_date) if last_piv else '',
|
| 1233 |
-
'context_pivot_end_date': self._ds(last_piv.end_date) if last_piv else ''},
|
| 1234 |
-
diverge_grade=None)
|
| 1235 |
-
return None
|
| 1236 |
-
|
| 1237 |
-
def detect_s3(self) -> Optional[Signal]:
|
| 1238 |
-
if self.n_bis < 5 or self.n_pivots < 1:
|
| 1239 |
-
return None
|
| 1240 |
-
last_piv = self.pivots[-1]
|
| 1241 |
-
zg, zd = last_piv.zg, last_piv.zd
|
| 1242 |
-
if len(self.bis) < 3:
|
| 1243 |
-
return None
|
| 1244 |
-
cur = self.bis[-1]
|
| 1245 |
-
if cur.direction != 'down':
|
| 1246 |
-
return None
|
| 1247 |
-
s1_price, s1_date = self._find_prev_s1()
|
| 1248 |
-
s2_price, s2_date = self._find_prev_s2()
|
| 1249 |
-
base_dates = {'price_date': self._ds(self.df_raw['date'].iloc[-1]),
|
| 1250 |
-
's1_price': s1_price, 's1_date': s1_date,
|
| 1251 |
-
's2_price': s2_price, 's2_date': s2_date,
|
| 1252 |
-
'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
|
| 1253 |
-
'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
|
| 1254 |
-
'pivot_start_date': self._ds(last_piv.start_date),
|
| 1255 |
-
'pivot_end_date': self._ds(last_piv.end_date)}
|
| 1256 |
-
pair = self._last_exit_pullback_segments(last_piv, 'down', 'up')
|
| 1257 |
-
if pair is None:
|
| 1258 |
-
return None
|
| 1259 |
-
exit_seg, pull_seg = pair
|
| 1260 |
-
if not (exit_seg.high >= zd * (1 - self.PIVOT_TOLERANCE) and exit_seg.low < zd):
|
| 1261 |
-
return None
|
| 1262 |
-
if pull_seg.high > zd * (1 + self.PIVOT_TOLERANCE):
|
| 1263 |
-
return None
|
| 1264 |
-
if float(self.close.iloc[-1]) >= zd:
|
| 1265 |
-
return None
|
| 1266 |
-
return Signal(kind='S3', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]),
|
| 1267 |
-
reason=f'标准三卖|ZD={zd:.3f}|线段离枢低{exit_seg.low:.3f}<ZD|线段回抽高{pull_seg.high:.3f}未过ZD',
|
| 1268 |
-
pivot_zg=zg, pivot_zd=zd, dif_value=float(self.dif.iloc[-1]),
|
| 1269 |
-
n_pivots=self.n_pivots, trend=self.trend, extras=base_dates, diverge_grade=None)
|
| 1270 |
-
|
| 1271 |
-
def get_signal(self) -> Optional[Signal]:
|
| 1272 |
-
for fn in [self.detect_s1, self.detect_s2, self.detect_s3]:
|
| 1273 |
-
sig = fn()
|
| 1274 |
-
if sig is not None:
|
| 1275 |
-
return sig
|
| 1276 |
-
for fn in [self.detect_b3, self.detect_b2, self.detect_b1]:
|
| 1277 |
-
sig = fn()
|
| 1278 |
-
if sig is not None:
|
| 1279 |
-
return sig
|
| 1280 |
-
return None
|
| 1281 |
-
|
| 1282 |
-
def get_all_signals(self) -> list:
|
| 1283 |
-
all_sigs = []
|
| 1284 |
-
for fn in [self.detect_b1, self.detect_b2, self.detect_b3,
|
| 1285 |
-
self.detect_s1, self.detect_s2, self.detect_s3]:
|
| 1286 |
-
sig = fn()
|
| 1287 |
-
if sig is not None:
|
| 1288 |
-
all_sigs.append(sig)
|
| 1289 |
-
return all_sigs
|
| 1290 |
-
|
| 1291 |
-
def l36_segment_note(self) -> str:
|
| 1292 |
-
"""L36 结合律: 走势分解的唯一性靠结合律保证 —— a+A+b+B+c 的划分中, 同一段
|
| 1293 |
-
K线不能既归前段又归后段。本引擎线段划分采用特征序列分型(标准缠论)处理
|
| 1294 |
-
包含关系, 分型一旦确认即锁定段的归属, 等价于结合律的程序化执行。
|
| 1295 |
-
该函数对当前末端给出"是否存在划分歧义"的提示。"""
|
| 1296 |
-
if len(self.segs) < 2:
|
| 1297 |
-
return '第36课: 线段不足2段, 无划分歧义问题'
|
| 1298 |
-
last = self.segs[-1]
|
| 1299 |
-
n_last = len(getattr(last, 'bis', []) or [])
|
| 1300 |
-
if n_last < 3:
|
| 1301 |
-
return (f'第36课: 末段仅{n_last}笔(<3), 末端划分尚未唯一确认 —— '
|
| 1302 |
-
f'当下操作应按两种归属做完全分类预案, 等待特征序列分型锁定')
|
| 1303 |
-
return '第36课: 末段≥3笔且特征序列分型已锁定, 当前划分唯一, 无歧义'
|
| 1304 |
-
|
| 1305 |
-
def diagnose(self) -> dict:
|
| 1306 |
-
cur_price = float(self.close.iloc[-1]) if len(self.close) else 0.0
|
| 1307 |
-
last = self.bis[-1] if self.bis else None
|
| 1308 |
-
out = {k: [] for k in ('B1', 'B2', 'B3', 'S1', 'S2', 'S3')}
|
| 1309 |
-
def add(k, ok, msg):
|
| 1310 |
-
out[k].append(('✓' if ok else '✗') + ' ' + msg)
|
| 1311 |
-
add('B1', self.n_bis >= 5, f'笔数 {self.n_bis} >= 5')
|
| 1312 |
-
add('B1', self.n_pivots >= 2, f'中枢数 {self.n_pivots} >= 2')
|
| 1313 |
-
add('B1', self.trend == 'down_trend', f'当前走势={self.trend}, B1要求下跌趋势')
|
| 1314 |
-
add('B1', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, B1要求向下')
|
| 1315 |
-
add('B1', float(self.dif.iloc[-1]) < 0 if len(self.dif) else False, f'DIF={float(self.dif.iloc[-1]):.4f}, B1要求DIF<0')
|
| 1316 |
-
abc_down = self._validate_abc('down')
|
| 1317 |
-
add('B1', abc_down is not None, 'A/B/C三段背驰结构成立')
|
| 1318 |
-
if abc_down is not None:
|
| 1319 |
-
c_ok, c_low = self._check_c_new_extreme(abc_down['c_start_idx'], 'down')
|
| 1320 |
-
add('B1', c_ok, f'C段创新低{"" if c_low is None else f"({c_low:.3f})"}')
|
| 1321 |
-
add('B2', self.n_bis >= 4, f'笔数 {self.n_bis} >= 4')
|
| 1322 |
-
add('B2', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, B2要求回踩向下')
|
| 1323 |
-
prev_downs = [b for b in self.bis[:-1] if b.direction == 'down'] if last else []
|
| 1324 |
-
add('B2', bool(prev_downs), '存在同一轮前一个下跌低点作为一买锚')
|
| 1325 |
-
if last and prev_downs:
|
| 1326 |
-
prev = prev_downs[-1]
|
| 1327 |
-
add('B2', last.low >= prev.low and last.end.price > prev.end.price,
|
| 1328 |
-
f'回踩不创新低: 本次低{last.end.price:.3f} > 一买/前低{prev.end.price:.3f}')
|
| 1329 |
-
add('B2', cur_price >= last.end.price * (1 - self.PIVOT_TOLERANCE),
|
| 1330 |
-
f'现价{cur_price:.3f}未跌破B2回踩锚{last.end.price:.3f}; 跌破则二买失效')
|
| 1331 |
-
add('B3', self.n_pivots >= 1, f'中枢数 {self.n_pivots} >= 1')
|
| 1332 |
-
add('B3', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, B3要求向上确认')
|
| 1333 |
-
if self.pivots:
|
| 1334 |
-
p = self.pivots[-1]
|
| 1335 |
-
pair = self._last_exit_pullback_segments(p, 'up', 'down')
|
| 1336 |
-
add('B3', pair is not None, '存在已确认线段级别的向上离枢 + 向下回试')
|
| 1337 |
-
if pair is not None:
|
| 1338 |
-
exit_seg, pull_seg = pair
|
| 1339 |
-
add('B3', exit_seg.low <= p.zg * (1 + self.PIVOT_TOLERANCE) and exit_seg.high > p.zg,
|
| 1340 |
-
f'离枢线段突破ZG: {exit_seg.low:.3f}~{exit_seg.high:.3f}, ZG={p.zg:.3f}')
|
| 1341 |
-
add('B3', pull_seg.low >= p.zg * (1 - self.PIVOT_TOLERANCE),
|
| 1342 |
-
f'回试低点{pull_seg.low:.3f}不破ZG={p.zg:.3f}')
|
| 1343 |
-
add('B3', cur_price > p.zg, f'现价{cur_price:.3f}站上ZG={p.zg:.3f}')
|
| 1344 |
-
add('S1', self.n_bis >= 5, f'笔数 {self.n_bis} >= 5')
|
| 1345 |
-
add('S1', self.n_pivots >= 2, f'中枢数 {self.n_pivots} >= 2')
|
| 1346 |
-
add('S1', self.trend == 'up_trend', f'当前走势={self.trend}, S1要求上涨趋势')
|
| 1347 |
-
add('S1', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, S1要求向上')
|
| 1348 |
-
abc_up = self._validate_abc('up')
|
| 1349 |
-
add('S1', abc_up is not None, 'A/B/C三段顶背驰结构成立')
|
| 1350 |
-
if abc_up is not None:
|
| 1351 |
-
c_ok, c_high = self._check_c_new_extreme(abc_up['c_start_idx'], 'up')
|
| 1352 |
-
add('S1', c_ok, f'C段创新高{"" if c_high is None else f"({c_high:.3f})"}')
|
| 1353 |
-
add('S2', self.n_bis >= 4, f'笔数 {self.n_bis} >= 4')
|
| 1354 |
-
add('S2', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, S2要求反弹向上')
|
| 1355 |
-
prev_ups = [b for b in self.bis[:-1] if b.direction == 'up'] if last else []
|
| 1356 |
-
add('S2', bool(prev_ups), '存在同一轮前一个上涨高点作为一卖锚')
|
| 1357 |
-
if last and prev_ups:
|
| 1358 |
-
prev = prev_ups[-1]
|
| 1359 |
-
add('S2', last.high < prev.high and last.end.price < prev.end.price,
|
| 1360 |
-
f'反弹不创新高: 本次高{last.end.price:.3f} < 一卖/前高{prev.end.price:.3f}')
|
| 1361 |
-
add('S2', cur_price <= last.end.price * (1 + self.PIVOT_TOLERANCE),
|
| 1362 |
-
f'现价{cur_price:.3f}未重新升破S2反弹锚{last.end.price:.3f}; 升破则二卖失效')
|
| 1363 |
-
add('S3', self.n_pivots >= 1, f'中枢数 {self.n_pivots} >= 1')
|
| 1364 |
-
add('S3', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, S3要求向下确认')
|
| 1365 |
-
if self.pivots:
|
| 1366 |
-
p = self.pivots[-1]
|
| 1367 |
-
pair = self._last_exit_pullback_segments(p, 'down', 'up')
|
| 1368 |
-
add('S3', pair is not None, '存在已确认线段级别的向下离枢 + 向上回抽')
|
| 1369 |
-
if pair is not None:
|
| 1370 |
-
exit_seg, pull_seg = pair
|
| 1371 |
-
add('S3', exit_seg.high >= p.zd * (1 - self.PIVOT_TOLERANCE) and exit_seg.low < p.zd,
|
| 1372 |
-
f'离枢线段跌破ZD: {exit_seg.low:.3f}~{exit_seg.high:.3f}, ZD={p.zd:.3f}')
|
| 1373 |
-
add('S3', pull_seg.high <= p.zd * (1 + self.PIVOT_TOLERANCE),
|
| 1374 |
-
f'回抽高点{pull_seg.high:.3f}不破ZD={p.zd:.3f}')
|
| 1375 |
-
add('S3', cur_price < p.zd, f'现价{cur_price:.3f}跌破ZD={p.zd:.3f}')
|
| 1376 |
-
return out
|
| 1377 |
-
|
| 1378 |
-
|
| 1379 |
-
class SameLevelDecomposition:
|
| 1380 |
-
def __init__(self, analyzer: 'ChanAnalyzer'):
|
| 1381 |
-
self.an = analyzer
|
| 1382 |
-
self.segs = analyzer.segs
|
| 1383 |
-
|
| 1384 |
-
def current_phase(self) -> dict:
|
| 1385 |
-
if len(self.segs) < 2:
|
| 1386 |
-
return {'seg_dir': '', 'stage': 'unknown', 'action': 'WATCH',
|
| 1387 |
-
'reason': '线段不足, 无法做同级别分解'}
|
| 1388 |
-
last = self.segs[-1]
|
| 1389 |
-
prev = self.segs[-2]
|
| 1390 |
-
seg_dir = last.direction
|
| 1391 |
-
if seg_dir == 'up':
|
| 1392 |
-
stage = 'up_run'
|
| 1393 |
-
prev_up = None
|
| 1394 |
-
for s in reversed(self.segs[:-1]):
|
| 1395 |
-
if s.direction == 'up':
|
| 1396 |
-
prev_up = s; break
|
| 1397 |
-
if prev_up is None:
|
| 1398 |
-
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'HOLD',
|
| 1399 |
-
'reason': '向上段运作中(无前向上段可比), 持有'}
|
| 1400 |
-
if last.high <= prev_up.high:
|
| 1401 |
-
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'SELL',
|
| 1402 |
-
'reason': f'向上段不创新高({last.high:.3f}≤前高{prev_up.high:.3f}) → 先卖(第38课)'}
|
| 1403 |
-
dg = self.an.assess_divergence(prev_up.start.date, prev_up.end.date,
|
| 1404 |
-
last.start.date, last.end.date, 'up')
|
| 1405 |
-
if dg.grade != 'NONE':
|
| 1406 |
-
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'SELL',
|
| 1407 |
-
'reason': f'向上段创新高但盘整背驰({dg.grade}) → 卖(第38课)'}
|
| 1408 |
-
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'HOLD',
|
| 1409 |
-
'reason': '向上段创新高且不背驰 → 持有(第38课)'}
|
| 1410 |
-
else:
|
| 1411 |
-
stage = 'down_run'
|
| 1412 |
-
prev_down = None
|
| 1413 |
-
for s in reversed(self.segs[:-1]):
|
| 1414 |
-
if s.direction == 'down':
|
| 1415 |
-
prev_down = s; break
|
| 1416 |
-
if prev_down is None:
|
| 1417 |
-
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'WATCH',
|
| 1418 |
-
'reason': '向下段运作中(无前向下段可比), 观望等买点'}
|
| 1419 |
-
if last.low >= prev_down.low:
|
| 1420 |
-
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'BUY',
|
| 1421 |
-
'reason': f'向下段不创新低({last.low:.3f}≥前低{prev_down.low:.3f}) → 买入(第38课)'}
|
| 1422 |
-
dg = self.an.assess_divergence(prev_down.start.date, prev_down.end.date,
|
| 1423 |
-
last.start.date, last.end.date, 'down')
|
| 1424 |
-
if dg.grade != 'NONE':
|
| 1425 |
-
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'BUY',
|
| 1426 |
-
'reason': f'向下段创新低但盘整背驰({dg.grade}) → 买入(第38课)'}
|
| 1427 |
-
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'WATCH',
|
| 1428 |
-
'reason': '向下段创新低且不背驰 → 观望等下跌背驰(第38课)'}
|
| 1429 |
-
|
| 1430 |
-
|
| 1431 |
-
class BottomTracker:
|
| 1432 |
-
def __init__(self):
|
| 1433 |
-
self.state = 'none'
|
| 1434 |
-
|
| 1435 |
-
def update(self, analyzer: 'ChanAnalyzer') -> str:
|
| 1436 |
-
snap = analyzer.bottom_construction_state()
|
| 1437 |
-
if self.state in ('none', 'failed', 'completed'):
|
| 1438 |
-
if snap == 'constructing':
|
| 1439 |
-
self.state = 'constructing'
|
| 1440 |
-
elif snap == 'completed':
|
| 1441 |
-
self.state = 'completed'
|
| 1442 |
-
elif snap == 'failed':
|
| 1443 |
-
self.state = 'failed'
|
| 1444 |
-
else:
|
| 1445 |
-
self.state = 'none'
|
| 1446 |
-
elif self.state == 'constructing':
|
| 1447 |
-
if snap == 'completed':
|
| 1448 |
-
self.state = 'completed'
|
| 1449 |
-
elif snap == 'failed':
|
| 1450 |
-
self.state = 'failed'
|
| 1451 |
-
return self.state
|
|
|
|
| 1 |
"""
|
| 2 |
+
Chan Compass — US edition · Spectrum 2 frontend (redesigned layout)
|
| 3 |
+
====================================================================
|
| 4 |
+
Same backend, cleaner face. EVERY feature of v3.9 is preserved — all six
|
| 5 |
+
tabs, every callback, every timer, the email rows, the fine-tune export and
|
| 6 |
+
the trace publisher — only the layout is reorganised for a simpler, more
|
| 7 |
+
beautiful Adobe Spectrum 2 experience:
|
| 8 |
+
|
| 9 |
+
• each tab opens with a framed "control bar" grouping its primary inputs
|
| 10 |
+
• the per-tab "Email this result" row is tucked into a collapsible accordion
|
| 11 |
+
• short eyebrow labels + help text replace dense paragraphs
|
| 12 |
+
• the signature accent-framed AI output panel (.llm-out)
|
| 13 |
+
|
| 14 |
+
The UI is mounted on a server with gr.mount_gradio_app (see server.py) — or
|
| 15 |
+
just `demo.launch()` here. Backend modules are imported unchanged.
|
| 16 |
"""
|
| 17 |
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
+
import os
|
| 20 |
+
import threading
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
import gradio as gr
|
| 23 |
+
import pandas as pd
|
| 24 |
|
| 25 |
+
import automation
|
| 26 |
+
import emailer
|
| 27 |
+
import finetune_data
|
| 28 |
+
import llm_local
|
| 29 |
+
import news_watch
|
| 30 |
+
import paths
|
| 31 |
+
import research_agent
|
| 32 |
+
import rotation
|
| 33 |
+
import signal_runner
|
| 34 |
+
|
| 35 |
+
# Spectrum 2 theme — single source of truth (tokens mirror the design system).
|
| 36 |
+
from theme import THEME as theme, CSS as S2_CSS
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ════════════════════════════════════════════════════ UI callbacks ══
|
| 40 |
+
# (identical to v3.9 — backend logic untouched)
|
| 41 |
+
def ui_run_signals(tickers_text, force):
|
| 42 |
+
"""Signals ONLY — pure rule engine, zero LLM, parallel downloads. Fast."""
|
| 43 |
+
import time
|
| 44 |
+
t0 = time.time()
|
| 45 |
+
tickers = [t for t in tickers_text.replace("\n", ",").split(",") if t.strip()]
|
| 46 |
+
df, details, summary, errors = signal_runner.run_signals(tickers or None,
|
| 47 |
+
force=bool(force))
|
| 48 |
+
if errors:
|
| 49 |
+
summary += " · some tickers skipped (data not ready yet — run again)"
|
| 50 |
+
automation.STATE["signals_df"] = df
|
| 51 |
+
automation.STATE["signals_details"] = details
|
| 52 |
+
automation.STATE["signals_summary"] = summary
|
| 53 |
+
choices = sorted(details.keys())
|
| 54 |
+
return (
|
| 55 |
+
df if df is not None else pd.DataFrame(),
|
| 56 |
+
f"{summary} · {time.time()-t0:.1f}s (rule engine only — no LLM in this path)",
|
| 57 |
+
gr.update(choices=choices, value=(choices[0] if choices else None)),
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def ui_show_detail(ticker):
|
| 62 |
+
if not ticker:
|
| 63 |
+
return "Select a ticker after running the analysis."
|
| 64 |
+
raw = signal_runner.stock_raw_read(ticker)
|
| 65 |
+
if not raw:
|
| 66 |
+
return "No data for this ticker yet — run the analysis first."
|
| 67 |
+
return f"**Raw read:**\n\n{raw}"
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def ui_explain_detail(ticker):
|
| 71 |
+
raw = signal_runner.stock_raw_read(ticker or "")
|
| 72 |
+
if not raw:
|
| 73 |
+
yield "Run the analysis and select a ticker first."
|
| 74 |
+
return
|
| 75 |
+
# The full Chan ruling chain (Chinese) is kept BACKSTAGE in STATE and fed to
|
| 76 |
+
# the model alongside the English raw read, so the summary reflects the real
|
| 77 |
+
# multi-timeframe reasoning — but the chain is never shown and output is
|
| 78 |
+
# English only. (This restores the merged raw-read + ruling-chain logic.)
|
| 79 |
+
chain = automation.STATE.get("signals_details", {}).get(ticker or "", "")
|
| 80 |
+
chain_core = chain.split("日线买卖点逐项诊断")[0].strip()[:2000] if chain else ""
|
| 81 |
+
prompt = ("You are an equity analyst. Write a SHORT plain-English summary "
|
| 82 |
+
"(≤100 words) for a long-term holder of a US stock: the situation "
|
| 83 |
+
"today, whether to act or wait, and the key price levels.\n"
|
| 84 |
+
"Use the FACT LINE for the numbers, and the RULING CHAIN (a "
|
| 85 |
+
"Chinese multi-timeframe Chan-theory decision log) for the reasoning "
|
| 86 |
+
"— translate and synthesize it; output ENGLISH ONLY, no Chinese "
|
| 87 |
+
"characters, do not quote the log, no disclaimers.\n\n"
|
| 88 |
+
f"FACT LINE:\n{raw}")
|
| 89 |
+
if chain_core:
|
| 90 |
+
prompt += f"\n\nRULING CHAIN (translate & synthesize, don't quote):\n{chain_core}"
|
| 91 |
+
yield "🤖 _Summary Sub-Agent (Chan-Tuned Qwen3-1.7B · llama.cpp) is explaining…_"
|
| 92 |
+
final = ""
|
| 93 |
+
for acc in llm_local.chat_stream(prompt, max_tokens=260, temperature=0.2,
|
| 94 |
+
worker="translator"):
|
| 95 |
+
final = acc
|
| 96 |
+
yield "🤖 **AI Summary (Summary Sub-Agent · Chan-Tuned Qwen3-1.7B):**\n\n" + acc
|
| 97 |
+
# capture (raw read → narrative) as a fine-tuning pair (🎯 Well-Tuned)
|
| 98 |
+
try:
|
| 99 |
+
import finetune_data
|
| 100 |
+
finetune_data.record(raw, final)
|
| 101 |
+
except Exception:
|
| 102 |
+
pass
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def ui_refresh_rotation():
|
| 106 |
+
"""Tables only — instant. AI narrative is a separate on-demand button."""
|
| 107 |
+
d1, d5, d20, asof = rotation.build_rotation(force=True)
|
| 108 |
+
automation.STATE["rotation"] = (d1, d5, d20, asof)
|
| 109 |
+
raw = rotation.rotation_brief(d1, d5, d20)
|
| 110 |
+
automation.STATE["rotation_narrative"] = raw
|
| 111 |
+
return (rotation.fmt_table(d1), rotation.fmt_table(d5), rotation.fmt_table(d20),
|
| 112 |
+
f"Sector flows as of **{asof}**", f"**Raw read:**\n{raw}")
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def ui_rotation_ai():
|
| 116 |
+
d1, d5, d20, asof = automation.STATE["rotation"]
|
| 117 |
+
if d1 is None:
|
| 118 |
+
yield "Refresh the rotation tables first."
|
| 119 |
+
return
|
| 120 |
+
brief = rotation.rotation_brief(d1, d5, d20)
|
| 121 |
+
prompt = ("You are a US equity market strategist. Based only on the sector flow "
|
| 122 |
+
"data below (SPDR ETF proxy: change% × dollar volume, plus RS vs SPY), "
|
| 123 |
+
"write a crisp brief (<150 words): 1) where capital is rotating INTO/OUT "
|
| 124 |
+
"OF; 2) do 1-day moves agree with the 5/20-day trend; 3) one watch item. "
|
| 125 |
+
"No disclaimers.\n\nDATA:\n" + brief[:2200])
|
| 126 |
+
yield ("🤖 _Narrator sub-agent (Qwen3-1.7B · llama.cpp) is reading the flow "
|
| 127 |
+
"tables — first words in ~5-15s…_")
|
| 128 |
+
for acc in llm_local.chat_stream(prompt, max_tokens=340, worker="narrator"):
|
| 129 |
+
yield "🤖 **Narrator sub-agent (Qwen3-1.7B · llama.cpp):**\n\n" + acc
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def ui_save_holdings(text):
|
| 133 |
+
saved = news_watch.save_holdings(text.replace("\n", ",").split(","))
|
| 134 |
+
return f"Saved {len(saved)} holding(s): {', '.join(saved) if saved else '—'}"
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def ui_check_news():
|
| 138 |
+
last = ""
|
| 139 |
+
for md in news_watch.check_holdings_news_stream():
|
| 140 |
+
last = md
|
| 141 |
+
yield md
|
| 142 |
+
automation.STATE["news_md"] = last
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def ui_research(ticker):
|
| 146 |
+
progress, report = "", ""
|
| 147 |
+
for progress, report in research_agent.run_research_stream(ticker):
|
| 148 |
+
yield progress, report, gr.update()
|
| 149 |
+
reports = research_agent.list_reports()
|
| 150 |
+
newest = reports[0] if reports else None
|
| 151 |
+
yield progress, report, gr.update(choices=reports, value=newest)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def ui_open_report(fname):
|
| 155 |
+
return research_agent.read_report(fname)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def ui_load_model(name):
|
| 159 |
+
return llm_local.load_model(name, worker="deep")
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
_SELFTEST = {"done": False, "result": ""}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _publish_traces(repo_id: str):
|
| 166 |
+
"""One-click: upload /data/traces to the Hub as a dataset, using the
|
| 167 |
+
HF_TOKEN secret. No command line needed."""
|
| 168 |
+
repo_id = (repo_id or "").strip()
|
| 169 |
+
if "/" not in repo_id:
|
| 170 |
+
return "⚠️ Enter a repo id like `username/dataset-name`."
|
| 171 |
+
token = (os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
|
| 172 |
+
or "").strip()
|
| 173 |
+
if not token:
|
| 174 |
+
return ("⚠️ No `HF_TOKEN` secret found. Add it in Space → Settings → "
|
| 175 |
+
"Variables and secrets (a **write** token), then try again.")
|
| 176 |
+
try:
|
| 177 |
+
import paths
|
| 178 |
+
traces = [f for f in os.listdir(paths.TRACES_DIR) if f.endswith(".json")]
|
| 179 |
+
except OSError:
|
| 180 |
+
traces = []
|
| 181 |
+
if not traces:
|
| 182 |
+
return "⚠️ No traces yet — run a few Auto Research reports first."
|
| 183 |
+
try:
|
| 184 |
+
from huggingface_hub import HfApi
|
| 185 |
+
api = HfApi(token=token)
|
| 186 |
+
api.create_repo(repo_id, repo_type="dataset", exist_ok=True)
|
| 187 |
+
# a small README so the dataset page explains itself
|
| 188 |
+
card = (
|
| 189 |
+
"---\nlicense: mit\ntags: [agent-trace, finance, chan-theory, llama-cpp]\n---\n\n"
|
| 190 |
+
"# Chan Compass — agent traces\n\n"
|
| 191 |
+
"JSON traces from the Chan Compass multi-agent research desk. Each file "
|
| 192 |
+
"is one ticker's run: the plan, every evidence-tool call and its result, "
|
| 193 |
+
"and each local sub-agent's request and response. Shared for the "
|
| 194 |
+
"Build Small hackathon (*Sharing is Caring*).\n\n"
|
| 195 |
+
"*Educational data — not investment advice.*\n")
|
| 196 |
+
import tempfile
|
| 197 |
+
rp = os.path.join(tempfile.gettempdir(), "README.md")
|
| 198 |
+
with open(rp, "w", encoding="utf-8") as f:
|
| 199 |
+
f.write(card)
|
| 200 |
+
api.upload_file(path_or_fileobj=rp, path_in_repo="README.md",
|
| 201 |
+
repo_id=repo_id, repo_type="dataset")
|
| 202 |
+
api.upload_folder(folder_path=paths.TRACES_DIR, path_in_repo="traces",
|
| 203 |
+
repo_id=repo_id, repo_type="dataset")
|
| 204 |
+
url = f"https://huggingface.co/datasets/{repo_id}"
|
| 205 |
+
return (f"✅ Published **{len(traces)}** trace(s) to [{repo_id}]({url}). "
|
| 206 |
+
f"Put that link in your submission for the *Sharing is Caring* badge.")
|
| 207 |
+
except Exception as e:
|
| 208 |
+
return f"❌ Upload failed: {e}"
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
def _export_dataset():
|
| 212 |
+
n = finetune_data.count()
|
| 213 |
+
if n == 0:
|
| 214 |
+
return ("⚠️ **0 training pairs captured yet.** Pairs are saved only when "
|
| 215 |
+
"the **Signals → AI summary** finishes with a model loaded. Steps: "
|
| 216 |
+
"1) Model tab — wait for the Summary sub-agent to show ✅; "
|
| 217 |
+
"2) Signals — Run analysis, pick a ticker, click **AI summary**, "
|
| 218 |
+
"let it finish; repeat a few times; 3) come back and Export.",
|
| 219 |
+
gr.update(visible=False))
|
| 220 |
+
path = finetune_data.export()
|
| 221 |
+
if not path:
|
| 222 |
+
return ("⚠️ Export failed to write the file (storage error). Try again.",
|
| 223 |
+
gr.update(visible=False))
|
| 224 |
+
# Copy to a folder under the app's working dir, which Gradio serves
|
| 225 |
+
# reliably (the /data bucket and /tmp are not in Gradio's allowed paths).
|
| 226 |
+
import shutil
|
| 227 |
+
served_dir = os.path.join(os.getcwd(), "exports")
|
| 228 |
+
os.makedirs(served_dir, exist_ok=True)
|
| 229 |
+
served = os.path.join(served_dir, os.path.basename(path))
|
| 230 |
+
try:
|
| 231 |
+
shutil.copy(path, served)
|
| 232 |
+
except OSError:
|
| 233 |
+
served = path
|
| 234 |
+
msg = (f"✅ Exported **{n}** captured pair(s). Download below, then follow "
|
| 235 |
+
f"`finetune/FINETUNE_GUIDE.md` to LoRA-tune Qwen3-1.7B and publish it.")
|
| 236 |
+
return msg, gr.update(value=served, visible=True)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def _run_selftest():
|
| 240 |
+
"""Run the self-test now (used by both the auto-timer and the manual button)
|
| 241 |
+
and cache the verdict so the two never fight over the output box."""
|
| 242 |
+
out = llm_local.quick_test()
|
| 243 |
+
ok = "not loaded" not in out and "error" not in out.lower()
|
| 244 |
+
_SELFTEST["done"] = True
|
| 245 |
+
_SELFTEST["result"] = (("✅ **Every agent is OK now** — self-test passed:\n\n" + out)
|
| 246 |
+
if ok else ("⚠️ Self-test finished with issues:\n\n" + out))
|
| 247 |
+
return _SELFTEST["result"]
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _auto_selftest():
|
| 251 |
+
"""Auto-runs ONCE the moment every sub-agent is loaded. After that it returns
|
| 252 |
+
the cached verdict unchanged, so it never overwrites a manual test result."""
|
| 253 |
+
if _SELFTEST["done"]:
|
| 254 |
+
return _SELFTEST["result"]
|
| 255 |
+
workers = llm_local.WORKERS
|
| 256 |
+
if not all(w["llm"] is not None for w in workers.values()):
|
| 257 |
+
ready = sum(1 for w in workers.values() if w["llm"] is not None)
|
| 258 |
+
return f"⏳ Loading sub-agents… {ready}/{len(workers)} ready (self-test will run automatically)."
|
| 259 |
+
return _run_selftest()
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _manual_selftest():
|
| 263 |
+
"""Manual button: always re-runs and shows the fresh verdict."""
|
| 264 |
+
return _run_selftest()
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# ════════════════════════════════════════════════════ layout ══
|
| 268 |
+
_GR_MAJOR = int(gr.__version__.split(".")[0])
|
| 269 |
+
_style_kw = {} if _GR_MAJOR >= 6 else {"theme": theme, "css": S2_CSS}
|
| 270 |
+
|
| 271 |
+
with gr.Blocks(title="Chan Compass · US", **_style_kw) as demo:
|
| 272 |
+
with gr.Column(elem_id="s2-app"):
|
| 273 |
+
gr.HTML("""
|
| 274 |
+
<div id="s2-hero">
|
| 275 |
+
<div class="mark">🧭</div>
|
| 276 |
+
<div>
|
| 277 |
+
<h1>Chan Compass <span>· US Markets</span></h1>
|
| 278 |
+
<p>Multi-timeframe 缠论 (Chan theory) engine — monthly → 1m nested-interval
|
| 279 |
+
confirmation · sector rotation · local sub-agent pool (llama.cpp).</p>
|
| 280 |
+
</div>
|
| 281 |
+
<div class="chips">
|
| 282 |
+
<span>🧠 Local · no cloud</span><span>🦙 llama.cpp</span>
|
| 283 |
+
<span>🤖 4 sub-agents</span><span>📊 Yahoo Finance</span>
|
| 284 |
+
<span>⏰ 18:10 ET</span><span>🎨 Spectrum 2</span>
|
| 285 |
+
</div>
|
| 286 |
+
</div>""")
|
| 287 |
+
|
| 288 |
+
# ─────────────────────────────────────── Signals ──
|
| 289 |
+
with gr.Tab("📈 Signals"):
|
| 290 |
+
gr.Markdown("STEP 1 · SCAN YOUR POOL", elem_classes=["s2-eyebrow"])
|
| 291 |
+
with gr.Group(elem_classes=["s2-bar"]):
|
| 292 |
+
with gr.Row():
|
| 293 |
+
tickers_in = gr.Textbox(value=", ".join(signal_runner.DEFAULT_POOL),
|
| 294 |
+
label="Ticker pool (comma separated)", scale=4)
|
| 295 |
+
force_cb = gr.Checkbox(value=False, label="Force fresh download", scale=1)
|
| 296 |
+
run_btn = gr.Button("▶ Run analysis", variant="primary", scale=1)
|
| 297 |
+
sig_summary = gr.Markdown(automation.STATE["signals_summary"])
|
| 298 |
+
sig_table = gr.Dataframe(label="Tomorrow's plan — long-hold mode (sorted: BUY → SELL → HOLD → WAIT)",
|
| 299 |
+
interactive=False, wrap=True)
|
| 300 |
+
|
| 301 |
+
gr.Markdown("STEP 2 · EXPLAIN ONE TICKER", elem_classes=["s2-eyebrow"])
|
| 302 |
+
gr.Markdown("Pick a ticker for a plain-English raw read, then let the Summary "
|
| 303 |
+
"sub-agent write an AI summary.", elem_classes=["s2-help"])
|
| 304 |
+
with gr.Group(elem_classes=["s2-bar"]):
|
| 305 |
+
with gr.Row():
|
| 306 |
+
detail_pick = gr.Dropdown(choices=[], label="Ticker", scale=2)
|
| 307 |
+
explain_btn = gr.Button("🤖 AI summary (local LLM)", variant="primary", scale=1)
|
| 308 |
+
detail_box = gr.Markdown(label="Raw read")
|
| 309 |
+
explain_box = gr.Markdown(elem_classes=["llm-out"])
|
| 310 |
+
with gr.Accordion("✉ Email this summary", open=False):
|
| 311 |
+
with gr.Row():
|
| 312 |
+
sig_email = gr.Textbox(label="Send to", scale=4,
|
| 313 |
+
placeholder="name@example.com (comma-separate for several)")
|
| 314 |
+
sig_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 315 |
+
sig_email_status = gr.Markdown()
|
| 316 |
+
|
| 317 |
+
# ─────────────────────────────────────── Sector Rotation ──
|
| 318 |
+
with gr.Tab("🔄 Sector Rotation"):
|
| 319 |
+
gr.Markdown("Capital rotation across the 11 SPDR sector ETFs (full S&P 500). "
|
| 320 |
+
"**Flow proxy = change % × dollar volume**; RS = return minus SPY. "
|
| 321 |
+
"True per-sector fund-flow feeds are paid data — this is the standard free proxy.",
|
| 322 |
+
elem_classes=["s2-help"])
|
| 323 |
+
with gr.Group(elem_classes=["s2-bar"]):
|
| 324 |
+
with gr.Row():
|
| 325 |
+
rot_btn = gr.Button("↻ Refresh rotation (instant)", variant="primary", scale=1)
|
| 326 |
+
rot_ai_btn = gr.Button("🤖 AI narrative (local LLM)", scale=1)
|
| 327 |
+
rot_asof = gr.Markdown("Press refresh (or Run analysis on the Signals tab).")
|
| 328 |
+
with gr.Row():
|
| 329 |
+
rot_1d = gr.Dataframe(label="1-Day (today's rotation)", interactive=False)
|
| 330 |
+
with gr.Row():
|
| 331 |
+
rot_5d = gr.Dataframe(label="5-Day (week trend)", interactive=False)
|
| 332 |
+
rot_20d = gr.Dataframe(label="20-Day (month trend)", interactive=False)
|
| 333 |
+
rot_ai = gr.Markdown(label="AI rotation narrative", elem_classes=["llm-out"])
|
| 334 |
+
with gr.Accordion("✉ Email this narrative", open=False):
|
| 335 |
+
with gr.Row():
|
| 336 |
+
rot_email = gr.Textbox(label="Send to", scale=4,
|
| 337 |
+
placeholder="name@example.com (comma-separate for several)")
|
| 338 |
+
rot_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 339 |
+
rot_email_status = gr.Markdown()
|
| 340 |
+
|
| 341 |
+
# ─────────────────────────────────────── Watchlist News ──
|
| 342 |
+
with gr.Tab("📰 Watchlist News"):
|
| 343 |
+
gr.Markdown("Daily rule: for each **holding**, only **today's** news is checked. "
|
| 344 |
+
"News found → AI brief below. No news → ticker is listed under *Quiet today*.",
|
| 345 |
+
elem_classes=["s2-help"])
|
| 346 |
+
with gr.Group(elem_classes=["s2-bar"]):
|
| 347 |
+
with gr.Row():
|
| 348 |
+
hold_in = gr.Textbox(value=", ".join(news_watch.load_holdings()),
|
| 349 |
+
label="My holdings (comma separated)", scale=3)
|
| 350 |
+
save_btn = gr.Button("💾 Save holdings", scale=1)
|
| 351 |
+
news_btn = gr.Button("🔍 Check today's news", variant="primary", scale=1)
|
| 352 |
+
hold_status = gr.Markdown()
|
| 353 |
+
news_out = gr.Markdown(elem_classes=["llm-out"])
|
| 354 |
+
with gr.Accordion("✉ Email this news brief", open=False):
|
| 355 |
+
with gr.Row():
|
| 356 |
+
news_email = gr.Textbox(label="Send to", scale=4,
|
| 357 |
+
placeholder="name@example.com (comma-separate for several)")
|
| 358 |
+
news_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 359 |
+
news_email_status = gr.Markdown()
|
| 360 |
+
|
| 361 |
+
# ─────────────────────────────────────── Auto Research ──
|
| 362 |
+
with gr.Tab("🧪 Auto Research"):
|
| 363 |
+
gr.Markdown("**Multi-step research agent** (fully local): PLAN → 5 evidence tools "
|
| 364 |
+
"(fundamentals · quarterly financials · price action · **the Chan engine "
|
| 365 |
+
"itself** · news) → section-by-section analysis → report. Every step is "
|
| 366 |
+
"logged to a JSON **agent trace**. New pool tickers get a report "
|
| 367 |
+
"**auto-generated** by the daily pipeline.", elem_classes=["s2-help"])
|
| 368 |
+
with gr.Group(elem_classes=["s2-bar"]):
|
| 369 |
+
with gr.Row():
|
| 370 |
+
res_in = gr.Textbox(label="Ticker", placeholder="e.g. NVDA", scale=3)
|
| 371 |
+
res_btn = gr.Button("🤖 Run research agent", variant="primary", scale=1)
|
| 372 |
+
res_progress = gr.Markdown()
|
| 373 |
+
res_out = gr.Markdown(elem_classes=["llm-out"])
|
| 374 |
+
with gr.Accordion("✉ Email this report", open=False):
|
| 375 |
+
with gr.Row():
|
| 376 |
+
res_email = gr.Textbox(label="Send to", scale=4,
|
| 377 |
+
placeholder="name@example.com (comma-separate for several)")
|
| 378 |
+
res_email_btn = gr.Button("✉ Send Email", scale=1)
|
| 379 |
+
res_email_status = gr.Markdown()
|
| 380 |
+
|
| 381 |
+
gr.Markdown("REPORT LIBRARY", elem_classes=["s2-eyebrow"])
|
| 382 |
+
gr.Markdown("Auto + manual reports, stored on `/data`.", elem_classes=["s2-help"])
|
| 383 |
+
with gr.Group(elem_classes=["s2-bar"]):
|
| 384 |
+
with gr.Row():
|
| 385 |
+
rep_pick = gr.Dropdown(choices=research_agent.list_reports(),
|
| 386 |
+
label="Saved reports", scale=3)
|
| 387 |
+
rep_open = gr.Button("📂 Open report", scale=1)
|
| 388 |
+
rep_view = gr.Markdown()
|
| 389 |
+
|
| 390 |
+
# ─────────────────────────────────────── Automation ──
|
| 391 |
+
with gr.Tab("⏰ Automation"):
|
| 392 |
+
gr.Markdown(paths.storage_status())
|
| 393 |
+
auto_md = gr.Markdown(automation.schedule_info())
|
| 394 |
+
with gr.Group(elem_classes=["s2-bar"]):
|
| 395 |
+
with gr.Row():
|
| 396 |
+
auto_now = gr.Button("⚡ Run now", variant="primary")
|
| 397 |
+
auto_msg = gr.Markdown()
|
| 398 |
+
auto_log = gr.Textbox(lines=14, label="Pipeline log (live — updates every 2s)",
|
| 399 |
+
elem_id="detail-log")
|
| 400 |
+
traces_md = gr.Markdown(research_agent.list_traces())
|
| 401 |
+
gr.Markdown("SHARE AGENT TRACES", elem_classes=["s2-eyebrow"])
|
| 402 |
+
gr.Markdown("Publish every JSON agent trace in `/data/traces` as a Hugging Face "
|
| 403 |
+
"**dataset** (one click, uses your `HF_TOKEN` secret — no command line). "
|
| 404 |
+
"Earns *Sharing is Caring*.", elem_classes=["s2-help"])
|
| 405 |
+
with gr.Group(elem_classes=["s2-bar"]):
|
| 406 |
+
with gr.Row():
|
| 407 |
+
trace_repo = gr.Textbox(
|
| 408 |
+
value="ranranrunforit/chan-compass-agent-traces",
|
| 409 |
+
label="Dataset repo id", scale=3)
|
| 410 |
+
trace_pub = gr.Button("📡 Publish traces as dataset", variant="primary", scale=1)
|
| 411 |
+
trace_pub_status = gr.Markdown()
|
| 412 |
+
trace_pub.click(_publish_traces, trace_repo, trace_pub_status)
|
| 413 |
+
auto_log_timer = gr.Timer(2.0)
|
| 414 |
+
|
| 415 |
+
def _auto_tick():
|
| 416 |
+
log = "\n".join(automation.STATE["log"][-40:]) or "(no log yet)"
|
| 417 |
+
return log, research_agent.list_traces()
|
| 418 |
+
|
| 419 |
+
auto_log_timer.tick(_auto_tick, None, [auto_log, traces_md])
|
| 420 |
+
|
| 421 |
+
# ─────────────────────────────────────── Model ──
|
| 422 |
+
with gr.Tab("🧠 Model"):
|
| 423 |
+
gr.Markdown("All AI runs **locally** through **llama.cpp** with Qwen3 GGUF weights — "
|
| 424 |
+
"every option is far below the 32B cap, and nothing leaves the machine. "
|
| 425 |
+
"**First load installs the runtime + downloads the GGUF (one-time, usually "
|
| 426 |
+
"1–3 min; worst case ~15 min if it compiles).** Signals/rotation/news never "
|
| 427 |
+
"depend on it.", elem_classes=["s2-help"])
|
| 428 |
+
gr.Markdown("**Sub-agent pool:** Summary (Chan-Tuned Qwen3-1.7B, fixed) handles "
|
| 429 |
+
"Explain / rotation narrative / news briefs; `deep` Analyst writes research "
|
| 430 |
+
"reports. Each has its own lock — they run in parallel.", elem_classes=["s2-help"])
|
| 431 |
+
with gr.Group(elem_classes=["s2-bar"]):
|
| 432 |
+
model_pick = gr.Radio(choices=list(llm_local.MODEL_ZOO.keys()),
|
| 433 |
+
value=llm_local.DEFAULT_MODEL, label="Analyst (deep) model")
|
| 434 |
+
with gr.Row():
|
| 435 |
+
load_btn = gr.Button("⬇ Load model", variant="primary", scale=1)
|
| 436 |
+
test_btn = gr.Button("⚡ Test sub-agents now", scale=1)
|
| 437 |
+
model_status = gr.Markdown(llm_local.status())
|
| 438 |
+
model_test_out = gr.Markdown()
|
| 439 |
+
model_timer = gr.Timer(2.0)
|
| 440 |
+
model_timer.tick(lambda: llm_local.status(), None, model_status)
|
| 441 |
+
autotest_timer = gr.Timer(3.0)
|
| 442 |
+
autotest_timer.tick(_auto_selftest, None, model_test_out)
|
| 443 |
+
|
| 444 |
+
gr.Markdown("🎯 FINE-TUNING DATASET", elem_classes=["s2-eyebrow"])
|
| 445 |
+
gr.Markdown("Every Signals **AI summary** you run is saved as a (raw read → narrative) "
|
| 446 |
+
"training pair on `/data`. Capture a few hundred, export the JSONL, then "
|
| 447 |
+
"follow `FINETUNE_GUIDE.md` to LoRA-tune Qwen3-1.7B and publish it.",
|
| 448 |
+
elem_classes=["s2-help"])
|
| 449 |
+
ft_status = gr.Markdown(finetune_data.status_line())
|
| 450 |
+
with gr.Group(elem_classes=["s2-bar"]):
|
| 451 |
+
ft_export = gr.Button("⬇ Export dataset (JSONL)", variant="primary")
|
| 452 |
+
ft_out = gr.Markdown()
|
| 453 |
+
ft_file = gr.File(label="Download training data", visible=False)
|
| 454 |
+
ft_timer = gr.Timer(3.0)
|
| 455 |
+
ft_timer.tick(lambda: finetune_data.status_line(), None, ft_status)
|
| 456 |
+
ft_export.click(_export_dataset, None, [ft_out, ft_file])
|
| 457 |
+
|
| 458 |
+
gr.Markdown("Chan Compass · educational tool, not investment advice · "
|
| 459 |
+
"data: Yahoo Finance · design language: Adobe Spectrum 2",
|
| 460 |
+
elem_classes=["s2-footnote"])
|
| 461 |
+
|
| 462 |
+
# ─────────────────────────────────────── wiring (unchanged) ──
|
| 463 |
+
run_btn.click(ui_run_signals, [tickers_in, force_cb],
|
| 464 |
+
[sig_table, sig_summary, detail_pick])
|
| 465 |
+
detail_pick.change(ui_show_detail, detail_pick, detail_box)
|
| 466 |
+
explain_btn.click(ui_explain_detail, detail_pick, explain_box)
|
| 467 |
+
rot_btn.click(ui_refresh_rotation, None, [rot_1d, rot_5d, rot_20d, rot_asof, rot_ai])
|
| 468 |
+
rot_ai_btn.click(ui_rotation_ai, None, rot_ai)
|
| 469 |
+
save_btn.click(ui_save_holdings, hold_in, hold_status)
|
| 470 |
+
news_btn.click(ui_check_news, None, news_out)
|
| 471 |
+
res_btn.click(ui_research, res_in, [res_progress, res_out, rep_pick])
|
| 472 |
+
sig_email_btn.click(lambda body, to: emailer.send_result(body, to, "Signals summary"),
|
| 473 |
+
[explain_box, sig_email], sig_email_status)
|
| 474 |
+
rot_email_btn.click(lambda body, to: emailer.send_result(body, to, "Sector rotation"),
|
| 475 |
+
[rot_ai, rot_email], rot_email_status)
|
| 476 |
+
news_email_btn.click(lambda body, to: emailer.send_result(body, to, "Watchlist news"),
|
| 477 |
+
[news_out, news_email], news_email_status)
|
| 478 |
+
res_email_btn.click(lambda body, to: emailer.send_result(body, to, "Research report"),
|
| 479 |
+
[res_out, res_email], res_email_status)
|
| 480 |
+
rep_open.click(ui_open_report, rep_pick, rep_view)
|
| 481 |
+
auto_now.click(lambda: automation.run_pipeline(force=True), None, auto_msg)
|
| 482 |
+
load_btn.click(ui_load_model, model_pick, model_status)
|
| 483 |
+
test_btn.click(_manual_selftest, None, model_test_out)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
# ════════════════════════════════════════════════════ boot ══
|
| 487 |
+
automation.start_scheduler()
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
def _auto_load_model():
|
| 491 |
+
try:
|
| 492 |
+
automation._log("Auto-loading sub-agents (llama.cpp)…")
|
| 493 |
+
llm_local.auto_load_all()
|
| 494 |
+
except Exception as e:
|
| 495 |
+
automation._log(f"Sub-agent auto-load failed: {e}")
|
| 496 |
+
|
| 497 |
+
|
| 498 |
+
try:
|
| 499 |
+
import paths as _paths
|
| 500 |
+
automation._log(_paths.storage_status())
|
| 501 |
+
except Exception:
|
| 502 |
+
pass
|
| 503 |
+
if os.environ.get("AUTO_LOAD_MODEL", "1") == "1":
|
| 504 |
+
threading.Thread(target=_auto_load_model, daemon=True).start()
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
if __name__ == "__main__":
|
| 508 |
+
import paths as _p
|
| 509 |
+
_allowed = [os.path.join(os.getcwd(), "exports"), _p.DATASET_DIR]
|
| 510 |
+
os.makedirs(_allowed[0], exist_ok=True)
|
| 511 |
+
if _GR_MAJOR >= 6:
|
| 512 |
+
demo.launch(theme=theme, css=S2_CSS, allowed_paths=_allowed)
|
| 513 |
+
else:
|
| 514 |
+
demo.launch(allowed_paths=_allowed)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chan_enhance.py
CHANGED
|
@@ -1,123 +1,147 @@
|
|
| 1 |
"""
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
from __future__ import annotations
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
return None
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
l37 = l37_divergence_note(ml)
|
| 112 |
-
if l37:
|
| 113 |
-
out['l37_note'] = l37
|
| 114 |
-
evo = recompute_evo(ml)
|
| 115 |
-
if evo:
|
| 116 |
-
out['post_evolution'] = evo
|
| 117 |
-
out['evo_hint'] = ('第29课: 最弱形态(反弹/回落未回中枢), 持有宜收紧移动止盈, 尽快兑现'
|
| 118 |
-
if evo == 'case1_extend'
|
| 119 |
-
else '第29课: 形态较强(回到中枢), 可持有等三买/趋势延续')
|
| 120 |
-
zd = ml.daily.zd
|
| 121 |
-
if zd is not None and ml.cur_price < zd:
|
| 122 |
-
out['l92_warn'] = f'第92课: 现价{ml.cur_price:.3f}已在日线中枢下沿ZD{zd:.3f}下方 → 向下变盘预警'
|
| 123 |
-
return out
|
|
|
|
| 1 |
"""
|
| 2 |
+
automation.py — daily auto-update pipeline.
|
| 3 |
+
|
| 4 |
+
Chosen update time (requirement #2): **18:10 America/New_York**, Mon–Fri.
|
| 5 |
+
Why: NYSE/Nasdaq close at 16:00 ET; the closing auction, after-hours prints
|
| 6 |
+
and Yahoo's consolidated daily bar settle over the following ~1–2 hours.
|
| 7 |
+
By 18:10 ET the official daily OHLCV is stable, so we always capture the
|
| 8 |
+
finished trading day exactly once (= 07:10 next morning Beijing time).
|
| 9 |
+
|
| 10 |
+
Pipeline per run:
|
| 11 |
+
1. refresh data for the signal pool + holdings + sector ETFs (yfinance)
|
| 12 |
+
2. re-run multi-level Chan signals → cached for the Signals tab
|
| 13 |
+
3. rebuild the sector rotation tables
|
| 14 |
+
4. check today's news for every holding (push brief / ignore if quiet)
|
| 15 |
+
|
| 16 |
+
NOTE for Hugging Face free Spaces: free hardware sleeps after ~48h without
|
| 17 |
+
traffic, and a sleeping Space cannot fire its scheduler. Everything here also
|
| 18 |
+
runs on demand via the "Run now" button; on paid "always-on" hardware the
|
| 19 |
+
schedule fires unattended.
|
| 20 |
"""
|
| 21 |
from __future__ import annotations
|
| 22 |
|
| 23 |
+
import datetime as dt
|
| 24 |
+
import threading
|
| 25 |
+
import traceback
|
| 26 |
+
from zoneinfo import ZoneInfo
|
| 27 |
+
|
| 28 |
+
NY = ZoneInfo("America/New_York")
|
| 29 |
+
RUN_HOUR, RUN_MINUTE = 18, 10
|
| 30 |
+
|
| 31 |
+
STATE = {
|
| 32 |
+
"signals_df": None,
|
| 33 |
+
"signals_details": {},
|
| 34 |
+
"signals_summary": "Not run yet — press “Run now” or wait for the 18:10 ET schedule.",
|
| 35 |
+
"rotation": (None, None, None, "—"),
|
| 36 |
+
"rotation_narrative": "",
|
| 37 |
+
"news_md": "",
|
| 38 |
+
"last_run": None,
|
| 39 |
+
"running": False,
|
| 40 |
+
"log": [],
|
| 41 |
}
|
| 42 |
+
_lock = threading.Lock()
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _log(msg: str):
|
| 46 |
+
stamp = dt.datetime.now(NY).strftime("%m-%d %H:%M:%S ET")
|
| 47 |
+
STATE["log"] = (STATE["log"] + [f"[{stamp}] {msg}"])[-60:]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def run_pipeline(tickers=None, force: bool = True) -> str:
|
| 51 |
+
"""Full daily refresh. Safe to call from the UI or the scheduler."""
|
| 52 |
+
import news_watch
|
| 53 |
+
import rotation
|
| 54 |
+
import signal_runner
|
| 55 |
+
|
| 56 |
+
with _lock:
|
| 57 |
+
if STATE["running"]:
|
| 58 |
+
return "A pipeline run is already in progress."
|
| 59 |
+
STATE["running"] = True
|
| 60 |
+
try:
|
| 61 |
+
_log("Pipeline start: refreshing data + signals…")
|
| 62 |
+
df, details, summary, errors = signal_runner.run_signals(tickers, force=force)
|
| 63 |
+
STATE["signals_df"] = df
|
| 64 |
+
STATE["signals_details"] = details
|
| 65 |
+
STATE["signals_summary"] = summary
|
| 66 |
+
for e in errors:
|
| 67 |
+
_log(f"signal skip: {e}")
|
| 68 |
+
_log(f"Signals done: {summary}")
|
| 69 |
+
|
| 70 |
+
d1, d5, d20, asof = rotation.build_rotation(force=force)
|
| 71 |
+
STATE["rotation"] = (d1, d5, d20, asof)
|
| 72 |
+
# LLM narrative is generated ON DEMAND from the Rotation tab button —
|
| 73 |
+
# the pipeline itself never waits on the model.
|
| 74 |
+
STATE["rotation_narrative"] = rotation.rotation_brief(d1, d5, d20)
|
| 75 |
+
_log(f"Sector rotation rebuilt (as of {asof}).")
|
| 76 |
+
|
| 77 |
+
STATE["news_md"] = news_watch.check_holdings_news()
|
| 78 |
+
_log("Holdings news checked.")
|
| 79 |
+
|
| 80 |
+
# ── Feature 4: auto research report for every NEW ticker in the pool ──
|
| 81 |
+
try:
|
| 82 |
+
import json
|
| 83 |
+
import os
|
| 84 |
+
import paths
|
| 85 |
+
import research_agent
|
| 86 |
+
known_path = os.path.join(paths.OUTPUT_DIR, "known_tickers.json")
|
| 87 |
+
try:
|
| 88 |
+
with open(known_path, encoding="utf-8") as f:
|
| 89 |
+
known = set(json.load(f))
|
| 90 |
+
except (OSError, ValueError):
|
| 91 |
+
known = set()
|
| 92 |
+
current = set(df["Ticker"].tolist()) if df is not None and len(df) else set()
|
| 93 |
+
new_tickers = sorted(current - known)
|
| 94 |
+
generated = set()
|
| 95 |
+
for t in new_tickers[:5]: # safety cap per run
|
| 96 |
+
_log(f"New ticker {t} → auto-generating research report…")
|
| 97 |
+
report, trace = research_agent.run_research(t, auto=True)
|
| 98 |
+
if report:
|
| 99 |
+
generated.add(t)
|
| 100 |
+
_log(f"Report for {t} done{' (+trace)' if trace else ''}.")
|
| 101 |
+
else:
|
| 102 |
+
_log(f"Report for {t} postponed (sub-agents still loading) — "
|
| 103 |
+
f"will retry on the next run.")
|
| 104 |
+
done_set = known | (current - (set(new_tickers) - generated))
|
| 105 |
+
if current:
|
| 106 |
+
with open(known_path, "w", encoding="utf-8") as f:
|
| 107 |
+
json.dump(sorted(done_set), f)
|
| 108 |
+
except Exception as e:
|
| 109 |
+
_log(f"Auto-research skipped: {e}")
|
| 110 |
+
|
| 111 |
+
STATE["last_run"] = dt.datetime.now(NY)
|
| 112 |
+
_log("Pipeline finished.")
|
| 113 |
+
return f"Done. {summary}"
|
| 114 |
+
except Exception as e:
|
| 115 |
+
traceback.print_exc()
|
| 116 |
+
_log(f"Pipeline error: {e}")
|
| 117 |
+
return f"Pipeline error: {e}"
|
| 118 |
+
finally:
|
| 119 |
+
STATE["running"] = False
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def start_scheduler():
|
| 123 |
+
"""Cron: Mon–Fri 18:10 America/New_York."""
|
| 124 |
+
try:
|
| 125 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
| 126 |
+
from apscheduler.triggers.cron import CronTrigger
|
| 127 |
+
except Exception as e:
|
| 128 |
+
_log(f"APScheduler unavailable: {e}")
|
| 129 |
return None
|
| 130 |
+
sched = BackgroundScheduler(timezone=NY)
|
| 131 |
+
sched.add_job(run_pipeline, CronTrigger(day_of_week="mon-fri",
|
| 132 |
+
hour=RUN_HOUR, minute=RUN_MINUTE),
|
| 133 |
+
id="daily_pipeline", max_instances=1, coalesce=True)
|
| 134 |
+
sched.start()
|
| 135 |
+
_log(f"Scheduler armed: Mon–Fri {RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York.")
|
| 136 |
+
return sched
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def schedule_info() -> str:
|
| 140 |
+
now = dt.datetime.now(NY)
|
| 141 |
+
last = STATE["last_run"].strftime("%Y-%m-%d %H:%M ET") if STATE["last_run"] else "never"
|
| 142 |
+
return (f"**Schedule:** Mon–Fri **{RUN_HOUR:02d}:{RUN_MINUTE:02d} America/New_York** "
|
| 143 |
+
f"(market closes 16:00 ET; by 18:10 the official daily bar has settled — "
|
| 144 |
+
f"that's 07:10 next morning Beijing time).\n\n"
|
| 145 |
+
f"**Now (ET):** {now.strftime('%Y-%m-%d %H:%M')} · **Last run:** {last}\n\n"
|
| 146 |
+
f"⚠️ On free Space hardware the app sleeps when idle and the timer can't fire; "
|
| 147 |
+
f"use **Run now**, or upgrade to always-on hardware for unattended updates.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
chan_glue.py
CHANGED
|
@@ -1,70 +1,1451 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 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 |
-
|
| 16 |
-
from collections import OrderedDict
|
| 17 |
|
| 18 |
-
import pandas as pd
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
缠论引擎 v2.1 (原始版, 用于P0-P3改造的基线)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
from __future__ import annotations
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Optional
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pandas as pd
|
| 9 |
|
| 10 |
+
PIVOT_MAX_EXTEND_SEGS = 6
|
|
|
|
| 11 |
|
|
|
|
| 12 |
|
| 13 |
+
@dataclass
|
| 14 |
+
class Fractal:
|
| 15 |
+
idx: int
|
| 16 |
+
date: pd.Timestamp
|
| 17 |
+
kind: str
|
| 18 |
+
price: float
|
| 19 |
+
k_high: float
|
| 20 |
+
k_low: float
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class Bi:
|
| 25 |
+
start: Fractal
|
| 26 |
+
end: Fractal
|
| 27 |
+
direction: str
|
| 28 |
+
bars: int
|
| 29 |
+
high: float
|
| 30 |
+
low: float
|
| 31 |
+
@property
|
| 32 |
+
def amplitude(self) -> float:
|
| 33 |
+
return self.high - self.low
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass
|
| 37 |
+
class Seg:
|
| 38 |
+
start: Fractal
|
| 39 |
+
end: Fractal
|
| 40 |
+
direction: str
|
| 41 |
+
bis: list
|
| 42 |
+
high: float
|
| 43 |
+
low: float
|
| 44 |
+
confirmed: bool = True
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass
|
| 48 |
+
class Pivot:
|
| 49 |
+
start_date: pd.Timestamp
|
| 50 |
+
end_date: pd.Timestamp
|
| 51 |
+
zg: float
|
| 52 |
+
zd: float
|
| 53 |
+
gg: float
|
| 54 |
+
dd: float
|
| 55 |
+
bis: list
|
| 56 |
+
direction: str
|
| 57 |
+
zg_date: Optional[pd.Timestamp] = None
|
| 58 |
+
zd_date: Optional[pd.Timestamp] = None
|
| 59 |
+
gg_date: Optional[pd.Timestamp] = None
|
| 60 |
+
dd_date: Optional[pd.Timestamp] = None
|
| 61 |
+
g: Optional[float] = None
|
| 62 |
+
d: Optional[float] = None
|
| 63 |
+
state: str = 'new'
|
| 64 |
+
death_combo: str = ''
|
| 65 |
+
capped: bool = False
|
| 66 |
+
upgraded_level: str = ''
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass
|
| 70 |
+
class DivergenceGrade:
|
| 71 |
+
grade: str
|
| 72 |
+
area_ok: bool
|
| 73 |
+
dif_ok: bool
|
| 74 |
+
area_ratio: float
|
| 75 |
+
a_area: float
|
| 76 |
+
c_area: float
|
| 77 |
+
a_dif: float
|
| 78 |
+
c_dif: float
|
| 79 |
+
direction: str
|
| 80 |
+
reason: str
|
| 81 |
+
is_trend_divergence: bool = False
|
| 82 |
+
n_trend_pivots: int = 0
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass
|
| 86 |
+
class Signal:
|
| 87 |
+
kind: str
|
| 88 |
+
date: pd.Timestamp
|
| 89 |
+
price: float
|
| 90 |
+
reason: str
|
| 91 |
+
pivot_zg: Optional[float] = None
|
| 92 |
+
pivot_zd: Optional[float] = None
|
| 93 |
+
macd_ratio: Optional[float] = None
|
| 94 |
+
dif_value: Optional[float] = None
|
| 95 |
+
n_pivots: int = 0
|
| 96 |
+
trend: str = ''
|
| 97 |
+
extras: dict = field(default_factory=dict)
|
| 98 |
+
diverge_grade: Optional[DivergenceGrade] = None
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def merge_klines(df: pd.DataFrame) -> pd.DataFrame:
|
| 102 |
+
if len(df) == 0:
|
| 103 |
+
return df.copy()
|
| 104 |
+
h = df['high'].values; l = df['low'].values; d = df['date'].values
|
| 105 |
+
out_h, out_l, out_d, out_idx = [h[0]], [l[0]], [d[0]], [0]
|
| 106 |
+
for i in range(1, len(df)):
|
| 107 |
+
ph, pl = out_h[-1], out_l[-1]; ch, cl = h[i], l[i]
|
| 108 |
+
direction = 1 if (len(out_h) >= 2 and out_h[-1] >= out_h[-2]) else (1 if len(out_h) < 2 else -1)
|
| 109 |
+
contained_a = ph >= ch and pl <= cl
|
| 110 |
+
contained_b = ch >= ph and cl <= pl
|
| 111 |
+
if contained_a or contained_b:
|
| 112 |
+
if direction >= 0:
|
| 113 |
+
out_h[-1] = max(ph, ch); out_l[-1] = max(pl, cl)
|
| 114 |
+
else:
|
| 115 |
+
out_h[-1] = min(ph, ch); out_l[-1] = min(pl, cl)
|
| 116 |
+
out_idx[-1] = i
|
| 117 |
+
else:
|
| 118 |
+
out_h.append(ch); out_l.append(cl); out_d.append(d[i]); out_idx.append(i)
|
| 119 |
+
return pd.DataFrame({'date': out_d, 'high': out_h, 'low': out_l, 'orig_idx': out_idx})
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def find_fractals(merged: pd.DataFrame) -> list:
|
| 123 |
+
res = []
|
| 124 |
+
n = len(merged)
|
| 125 |
+
if n < 3:
|
| 126 |
+
return res
|
| 127 |
+
h = merged['high'].values; l = merged['low'].values; d = merged['date'].values
|
| 128 |
+
hi = h[1:-1]; hp = h[:-2]; hn = h[2:]
|
| 129 |
+
li = l[1:-1]; lp = l[:-2]; ln = l[2:]
|
| 130 |
+
top = (hi > hp) & (hi > hn) & (li >= lp) & (li >= ln)
|
| 131 |
+
bot = (li < lp) & (li < ln) & (hi <= hp) & (hi <= hn)
|
| 132 |
+
idxs = np.nonzero(top | bot)[0]
|
| 133 |
+
if len(idxs) == 0:
|
| 134 |
+
return res
|
| 135 |
+
# 仅对被选中的(稀疏)分型点构造 Timestamp, 避免对全序列逐根转换
|
| 136 |
+
is_top = top # 局部别名
|
| 137 |
+
for j in idxs:
|
| 138 |
+
i = j + 1
|
| 139 |
+
if is_top[j]:
|
| 140 |
+
res.append(Fractal(i, pd.Timestamp(d[i]), 'top', float(h[i]), float(h[i]), float(l[i])))
|
| 141 |
+
else:
|
| 142 |
+
res.append(Fractal(i, pd.Timestamp(d[i]), 'bottom', float(l[i]), float(h[i]), float(l[i])))
|
| 143 |
+
return res
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def find_bis(fractals: list, min_k: int = 4) -> list:
|
| 147 |
+
if len(fractals) < 2:
|
| 148 |
+
return []
|
| 149 |
+
cleaned = [fractals[0]]
|
| 150 |
+
for fx in fractals[1:]:
|
| 151 |
+
last = cleaned[-1]
|
| 152 |
+
if fx.kind == last.kind:
|
| 153 |
+
if fx.kind == 'top' and fx.price > last.price:
|
| 154 |
+
cleaned[-1] = fx
|
| 155 |
+
elif fx.kind == 'bottom' and fx.price < last.price:
|
| 156 |
+
cleaned[-1] = fx
|
| 157 |
+
else:
|
| 158 |
+
cleaned.append(fx)
|
| 159 |
+
alt = [cleaned[0]]
|
| 160 |
+
for fx in cleaned[1:]:
|
| 161 |
+
if fx.kind != alt[-1].kind and fx.idx - alt[-1].idx >= min_k - 1:
|
| 162 |
+
alt.append(fx)
|
| 163 |
+
elif fx.kind != alt[-1].kind:
|
| 164 |
+
continue
|
| 165 |
+
bis = []
|
| 166 |
+
for i in range(len(alt) - 1):
|
| 167 |
+
a, b = alt[i], alt[i+1]
|
| 168 |
+
if a.kind == b.kind:
|
| 169 |
+
continue
|
| 170 |
+
direction = 'up' if b.kind == 'top' else 'down'
|
| 171 |
+
bis.append(Bi(start=a, end=b, direction=direction, bars=b.idx - a.idx,
|
| 172 |
+
high=max(a.price, b.price), low=min(a.price, b.price)))
|
| 173 |
+
return bis
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _find_feature_fractal(std: list, seg_dir: str):
|
| 177 |
+
"""[保留] 供调试/对照用的非增量实现; 主路径已改用 _first_feature_fractal_incremental。"""
|
| 178 |
+
up = (seg_dir == 'up')
|
| 179 |
+
for i in range(1, len(std) - 1):
|
| 180 |
+
a = std[i-1]; b = std[i]; c = std[i+1]
|
| 181 |
+
if up:
|
| 182 |
+
if b['high'] > a['high'] and b['high'] > c['high'] \
|
| 183 |
+
and b['low'] > a['low'] and b['low'] > c['low']:
|
| 184 |
+
return (i, b.get('has_gap_before', False))
|
| 185 |
+
else:
|
| 186 |
+
if b['low'] < a['low'] and b['low'] < c['low'] \
|
| 187 |
+
and b['high'] < a['high'] and b['high'] < c['high']:
|
| 188 |
+
return (i, b.get('has_gap_before', False))
|
| 189 |
+
return None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _first_feature_fractal_incremental(bis, start_i, end_i, seg_dir):
|
| 193 |
+
"""增量构建特征序列, 在第一个特征分型被"锁定"时立即返回。
|
| 194 |
+
|
| 195 |
+
锁定条件: 出现特征分型(a,b,c)后, 再追加一个新的标准元素(即 c 之后
|
| 196 |
+
已有一个不被包含的元素 d)。此时 c 不会再被向后合并改变, 分型 b 的
|
| 197 |
+
左右高低关系已固定, 与"先把整段展开再找首个分型"语义等价。
|
| 198 |
+
返回 (b_first_bi_idx, b_has_gap_before) 或 None。
|
| 199 |
+
"""
|
| 200 |
+
feat_dir = 'down' if seg_dir == 'up' else 'up'
|
| 201 |
+
up = (seg_dir == 'up')
|
| 202 |
+
std = [] # 每元素: [high, low, bi_idx, has_gap_before, first_bi_idx]
|
| 203 |
+
for k in range(start_i, end_i + 1):
|
| 204 |
+
b = bis[k]
|
| 205 |
+
if b.direction != feat_dir:
|
| 206 |
+
continue
|
| 207 |
+
ch = b.high; cl = b.low
|
| 208 |
+
if not std:
|
| 209 |
+
std.append([ch, cl, k, False, k]); continue
|
| 210 |
+
prev = std[-1]
|
| 211 |
+
ph = prev[0]; pl = prev[1]
|
| 212 |
+
contained = (ph >= ch and pl <= cl) or (ch >= ph and cl <= pl)
|
| 213 |
+
if contained:
|
| 214 |
+
if up:
|
| 215 |
+
prev[0] = ph if ph > ch else ch
|
| 216 |
+
prev[1] = pl if pl > cl else cl
|
| 217 |
+
else:
|
| 218 |
+
prev[0] = ph if ph < ch else ch
|
| 219 |
+
prev[1] = pl if pl < cl else cl
|
| 220 |
+
prev[2] = k
|
| 221 |
+
continue
|
| 222 |
+
std.append([ch, cl, k, gap_flag(cl, ch, ph, pl)] + [k])
|
| 223 |
+
# 锁定检查: 需要至少4个已定型元素, 才能保证倒数第3个(候选分型b)
|
| 224 |
+
# 的右邻c已被其后元素d终结、不会再被向后合并。
|
| 225 |
+
if len(std) >= 4:
|
| 226 |
+
a = std[-4]; bm = std[-3]; c = std[-2]
|
| 227 |
+
if up:
|
| 228 |
+
ok = (bm[0] > a[0] and bm[0] > c[0] and bm[1] > a[1] and bm[1] > c[1])
|
| 229 |
+
else:
|
| 230 |
+
ok = (bm[1] < a[1] and bm[1] < c[1] and bm[0] < a[0] and bm[0] < c[0])
|
| 231 |
+
if ok:
|
| 232 |
+
return (bm[4], bm[3])
|
| 233 |
+
# 收尾: 末端无后继元素, 用最终 std 找首个内部分型(与原实现等价)
|
| 234 |
+
for i in range(1, len(std) - 1):
|
| 235 |
+
a = std[i-1]; bm = std[i]; c = std[i+1]
|
| 236 |
+
if up:
|
| 237 |
+
ok = (bm[0] > a[0] and bm[0] > c[0] and bm[1] > a[1] and bm[1] > c[1])
|
| 238 |
+
else:
|
| 239 |
+
ok = (bm[1] < a[1] and bm[1] < c[1] and bm[0] < a[0] and bm[0] < c[0])
|
| 240 |
+
if ok:
|
| 241 |
+
return (bm[4], bm[3])
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
def gap_flag(cl, ch, ph, pl):
|
| 246 |
+
return (cl > ph) or (ch < pl)
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def _seq_fractal_confirms_reversal(bis, start_i, end_i, cur_dir):
|
| 250 |
+
fr = _first_feature_fractal_incremental(bis, start_i, end_i, cur_dir)
|
| 251 |
+
if fr is None:
|
| 252 |
+
return (False, None)
|
| 253 |
+
feat_first_bi, has_gap = fr
|
| 254 |
+
seg_end_bi = feat_first_bi - 1
|
| 255 |
+
if seg_end_bi <= start_i:
|
| 256 |
+
return (False, None)
|
| 257 |
+
if has_gap:
|
| 258 |
+
opp = 'down' if cur_dir == 'up' else 'up'
|
| 259 |
+
fr2 = _first_feature_fractal_incremental(bis, feat_first_bi, end_i, opp)
|
| 260 |
+
if fr2 is None:
|
| 261 |
+
return (False, None)
|
| 262 |
+
return (True, seg_end_bi)
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def find_segs(bis: list) -> list:
|
| 266 |
+
n = len(bis)
|
| 267 |
+
if n < 3:
|
| 268 |
+
return []
|
| 269 |
+
base = bis[0].start.price
|
| 270 |
+
look = min(3, n)
|
| 271 |
+
net = bis[look - 1].end.price - base
|
| 272 |
+
cur_dir = 'up' if net > 0 else 'down'
|
| 273 |
+
segs = []
|
| 274 |
+
i = 0
|
| 275 |
+
while i < n - 2:
|
| 276 |
+
confirmed, seg_end_bi = _seq_fractal_confirms_reversal(bis, i, n - 1, cur_dir)
|
| 277 |
+
if confirmed and seg_end_bi > i:
|
| 278 |
+
seg_bis = bis[i:seg_end_bi + 1]
|
| 279 |
+
net_up = seg_bis[-1].end.price > seg_bis[0].start.price
|
| 280 |
+
if (cur_dir == 'up') == net_up:
|
| 281 |
+
segs.append(Seg(start=bis[i].start, end=seg_bis[-1].end, direction=cur_dir,
|
| 282 |
+
bis=seg_bis, high=max(b.high for b in seg_bis),
|
| 283 |
+
low=min(b.low for b in seg_bis), confirmed=True))
|
| 284 |
+
i = seg_end_bi + 1
|
| 285 |
+
cur_dir = 'down' if cur_dir == 'up' else 'up'
|
| 286 |
+
continue
|
| 287 |
+
alt = 'down' if cur_dir == 'up' else 'up'
|
| 288 |
+
confirmed2, seg_end_bi2 = _seq_fractal_confirms_reversal(bis, i, n - 1, alt)
|
| 289 |
+
if confirmed2 and seg_end_bi2 > i:
|
| 290 |
+
seg_bis = bis[i:seg_end_bi2 + 1]
|
| 291 |
+
net_up = seg_bis[-1].end.price > seg_bis[0].start.price
|
| 292 |
+
if (alt == 'up') == net_up:
|
| 293 |
+
segs.append(Seg(start=seg_bis[0].start, end=seg_bis[-1].end, direction=alt,
|
| 294 |
+
bis=seg_bis, high=max(b.high for b in seg_bis),
|
| 295 |
+
low=min(b.low for b in seg_bis), confirmed=True))
|
| 296 |
+
i = seg_end_bi2 + 1
|
| 297 |
+
cur_dir = 'down' if alt == 'up' else 'up'
|
| 298 |
+
continue
|
| 299 |
+
break
|
| 300 |
+
if i < n - 1 and (n - i) >= 1:
|
| 301 |
+
seg_bis = bis[i:]
|
| 302 |
+
if len(seg_bis) >= 1:
|
| 303 |
+
net_up = seg_bis[-1].end.price > seg_bis[0].start.price
|
| 304 |
+
d = 'up' if net_up else 'down'
|
| 305 |
+
segs.append(Seg(start=seg_bis[0].start, end=seg_bis[-1].end, direction=d,
|
| 306 |
+
bis=seg_bis, high=max(b.high for b in seg_bis),
|
| 307 |
+
low=min(b.low for b in seg_bis), confirmed=False))
|
| 308 |
+
return segs
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
PIVOT_UPGRADE_SPAN_DAYS = 540
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def find_pivots(bis: list, segs: Optional[list] = None) -> list:
|
| 315 |
+
confirmed_segs = [s for s in (segs or []) if getattr(s, 'confirmed', True)]
|
| 316 |
+
units = confirmed_segs if len(confirmed_segs) >= 3 else bis
|
| 317 |
+
using_segs = units is confirmed_segs
|
| 318 |
+
pivots = []; n = len(units)
|
| 319 |
+
if n < 3:
|
| 320 |
+
return pivots
|
| 321 |
+
|
| 322 |
+
bi_pos = {id(b): k for k, b in enumerate(bis)}
|
| 323 |
+
|
| 324 |
+
def _unit_bi_range(u):
|
| 325 |
+
if hasattr(u, 'bis'):
|
| 326 |
+
idxs = [bi_pos[id(b)] for b in u.bis if id(b) in bi_pos]
|
| 327 |
+
return (min(idxs), max(idxs)) if idxs else (0, 0)
|
| 328 |
+
k = bi_pos.get(id(u), 0)
|
| 329 |
+
return (k, k)
|
| 330 |
+
|
| 331 |
+
def _unit_bi_indices(unit_list):
|
| 332 |
+
out = []
|
| 333 |
+
for u in unit_list:
|
| 334 |
+
a, b = _unit_bi_range(u)
|
| 335 |
+
out.extend(range(a, b + 1))
|
| 336 |
+
return sorted(set(out))
|
| 337 |
+
|
| 338 |
+
def _unit_high_date(u):
|
| 339 |
+
return u.start.date if u.start.price >= u.end.price else u.end.date
|
| 340 |
+
|
| 341 |
+
def _unit_low_date(u):
|
| 342 |
+
return u.start.date if u.start.price <= u.end.price else u.end.date
|
| 343 |
+
|
| 344 |
+
max_ext = PIVOT_MAX_EXTEND_SEGS if PIVOT_MAX_EXTEND_SEGS else 10 ** 9
|
| 345 |
+
|
| 346 |
+
i = 0
|
| 347 |
+
while i <= n - 3:
|
| 348 |
+
b1, b2, b3 = units[i], units[i+1], units[i+2]
|
| 349 |
+
r1 = (b1.low, b1.high)
|
| 350 |
+
r2 = (b2.low, b2.high)
|
| 351 |
+
r3 = (b3.low, b3.high)
|
| 352 |
+
zg = min(r1[1], r2[1], r3[1]); zd = max(r1[0], r2[0], r3[0])
|
| 353 |
+
if zg > zd:
|
| 354 |
+
direction = b1.direction; zg_orig, zd_orig = zg, zd; gg, dd = zg, zd
|
| 355 |
+
highs = [r1[1], r2[1], r3[1]]; lows = [r1[0], r2[0], r3[0]]
|
| 356 |
+
zg_bi = (b1, b2, b3)[highs.index(zg_orig)]
|
| 357 |
+
zd_bi = (b1, b2, b3)[lows.index(zd_orig)]
|
| 358 |
+
zg_d = _unit_high_date(zg_bi)
|
| 359 |
+
zd_d = _unit_low_date(zd_bi)
|
| 360 |
+
gg_d, dd_d = zg_d, zd_d
|
| 361 |
+
zn_dir = direction
|
| 362 |
+
gn_list = []; dn_list = []
|
| 363 |
+
for bb in (b1, b2, b3):
|
| 364 |
+
if bb.direction == zn_dir:
|
| 365 |
+
gn_list.append(max(bb.start.price, bb.end.price))
|
| 366 |
+
dn_list.append(min(bb.start.price, bb.end.price))
|
| 367 |
+
piv_units = [i, i+1, i+2]; j = i + 3
|
| 368 |
+
capped = False
|
| 369 |
+
while j < n:
|
| 370 |
+
if (len(piv_units) - 3) >= max_ext:
|
| 371 |
+
capped = True
|
| 372 |
+
break
|
| 373 |
+
bj = units[j]; lo_j = bj.low; hi_j = bj.high
|
| 374 |
+
if hi_j >= zd_orig and lo_j <= zg_orig:
|
| 375 |
+
if hi_j > gg:
|
| 376 |
+
gg = hi_j; gg_d = _unit_high_date(bj)
|
| 377 |
+
if lo_j < dd:
|
| 378 |
+
dd = lo_j; dd_d = _unit_low_date(bj)
|
| 379 |
+
if bj.direction == zn_dir:
|
| 380 |
+
gn_list.append(hi_j); dn_list.append(lo_j)
|
| 381 |
+
piv_units.append(j); j += 1
|
| 382 |
+
else:
|
| 383 |
+
break
|
| 384 |
+
g_val = min(gn_list) if gn_list else zg_orig
|
| 385 |
+
d_val = max(dn_list) if dn_list else zd_orig
|
| 386 |
+
piv_bis = _unit_bi_indices([units[k] for k in piv_units]) if using_segs else piv_units
|
| 387 |
+
p_start = b1.start.date
|
| 388 |
+
p_end = units[piv_units[-1]].end.date
|
| 389 |
+
piv = Pivot(start_date=p_start, end_date=p_end,
|
| 390 |
+
zg=zg_orig, zd=zd_orig, gg=gg, dd=dd, bis=piv_bis, direction=direction,
|
| 391 |
+
zg_date=zg_d, zd_date=zd_d, gg_date=gg_d, dd_date=dd_d,
|
| 392 |
+
g=g_val, d=d_val, capped=capped)
|
| 393 |
+
try:
|
| 394 |
+
span_days = (pd.Timestamp(p_end) - pd.Timestamp(p_start)).days
|
| 395 |
+
except Exception:
|
| 396 |
+
span_days = 0
|
| 397 |
+
if capped or span_days > PIVOT_UPGRADE_SPAN_DAYS:
|
| 398 |
+
piv.upgraded_level = 'weekly'
|
| 399 |
+
pivots.append(piv)
|
| 400 |
+
i = piv_units[-1] + 1
|
| 401 |
+
else:
|
| 402 |
+
i += 1
|
| 403 |
+
|
| 404 |
+
for k in range(1, len(pivots)):
|
| 405 |
+
prev, cur = pivots[k-1], pivots[k]
|
| 406 |
+
no_overlap = (cur.dd > prev.gg) or (cur.gg < prev.dd)
|
| 407 |
+
if no_overlap:
|
| 408 |
+
cur.state = 'new'
|
| 409 |
+
else:
|
| 410 |
+
cur.state = 'expand'
|
| 411 |
+
|
| 412 |
+
for k in range(len(pivots) - 1):
|
| 413 |
+
cur = pivots[k]
|
| 414 |
+
nxt = pivots[k + 1]
|
| 415 |
+
gap_start = cur.bis[-1] + 1
|
| 416 |
+
gap_end = nxt.bis[0]
|
| 417 |
+
gap_bis = bis[gap_start:gap_end] if gap_end > gap_start else []
|
| 418 |
+
if len(gap_bis) >= 2:
|
| 419 |
+
leave = gap_bis[:max(1, len(gap_bis) // 2)]
|
| 420 |
+
pull = gap_bis[max(1, len(gap_bis) // 2):]
|
| 421 |
+
leave_trend = len(leave) >= 3
|
| 422 |
+
pull_trend = len(pull) >= 3
|
| 423 |
+
if leave_trend and not pull_trend:
|
| 424 |
+
cur.death_combo = 'trend+consol'
|
| 425 |
+
elif leave_trend and pull_trend:
|
| 426 |
+
cur.death_combo = 'trend+counter'
|
| 427 |
+
else:
|
| 428 |
+
cur.death_combo = 'consol+counter'
|
| 429 |
+
return pivots
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
def classify_trend(pivots: list) -> str:
|
| 433 |
+
if len(pivots) < 2:
|
| 434 |
+
return 'consolidation'
|
| 435 |
+
p1, p2 = pivots[-2], pivots[-1]
|
| 436 |
+
if p2.dd > p1.gg:
|
| 437 |
+
return 'up_trend'
|
| 438 |
+
if p2.gg < p1.dd:
|
| 439 |
+
return 'down_trend'
|
| 440 |
+
if (p2.zg < p1.zd and p2.gg >= p1.dd) or (p2.zd > p1.zg and p2.dd <= p1.gg):
|
| 441 |
+
return 'expanding'
|
| 442 |
+
return 'consolidation'
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def count_trend_pivots(pivots: list) -> int:
|
| 446 |
+
if not pivots:
|
| 447 |
+
return 0
|
| 448 |
+
if len(pivots) == 1:
|
| 449 |
+
return 1
|
| 450 |
+
cnt = 1
|
| 451 |
+
for k in range(len(pivots) - 1, 0, -1):
|
| 452 |
+
p_prev, p_cur = pivots[k - 1], pivots[k]
|
| 453 |
+
if p_cur.dd > p_prev.gg:
|
| 454 |
+
cnt += 1
|
| 455 |
+
elif p_cur.gg < p_prev.dd:
|
| 456 |
+
cnt += 1
|
| 457 |
+
else:
|
| 458 |
+
break
|
| 459 |
+
return cnt
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
def calc_macd(close: pd.Series, fast=12, slow=26, signal=9):
|
| 463 |
+
ema_fast = close.ewm(span=fast, adjust=False).mean()
|
| 464 |
+
ema_slow = close.ewm(span=slow, adjust=False).mean()
|
| 465 |
+
dif = ema_fast - ema_slow
|
| 466 |
+
dea = dif.ewm(span=signal, adjust=False).mean()
|
| 467 |
+
macd_bar = 2 * (dif - dea)
|
| 468 |
+
return dif, dea, macd_bar
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def macd_area_between(start_date, end_date, bar_series, date_series, direction):
|
| 472 |
+
mask = (date_series >= start_date) & (date_series <= end_date)
|
| 473 |
+
vals = bar_series[mask]
|
| 474 |
+
if len(vals) == 0:
|
| 475 |
+
return 0.0
|
| 476 |
+
if direction == 'up':
|
| 477 |
+
return float(vals.clip(lower=0).sum())
|
| 478 |
+
return float(vals.clip(upper=0).abs().sum())
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def dif_extreme_in(start_date, end_date, dif_series, date_series, kind='peak'):
|
| 482 |
+
mask = (date_series >= start_date) & (date_series <= end_date)
|
| 483 |
+
vals = dif_series[mask]
|
| 484 |
+
if len(vals) == 0:
|
| 485 |
+
return 0.0
|
| 486 |
+
return float(vals.max()) if kind == 'peak' else float(vals.min())
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
class ChanAnalyzer:
|
| 490 |
+
DIVERGE_RATIO = 0.80
|
| 491 |
+
PIVOT_TOLERANCE = 0.02
|
| 492 |
+
MIN_BI_BARS = 4
|
| 493 |
+
DIF_TOLERANCE = 0.01
|
| 494 |
+
|
| 495 |
+
CFG = {
|
| 496 |
+
'b1_allow_consol_diverge': True,
|
| 497 |
+
'b3s3_first_pullback_only': False,
|
| 498 |
+
'b2s2_anchor_to_first': False,
|
| 499 |
+
'b2_macd_zero_pullback': False,
|
| 500 |
+
'drop_upgraded_pivots': False,
|
| 501 |
+
# L88-90 中阴阶段MACD精确运用: 中阴判定除BOLL收口外, 加入MACD特征
|
| 502 |
+
# 'off' = 维持原判定(仅BOLL收口+末笔未离开中枢)
|
| 503 |
+
# 'and' = 须同时��足"黄白线绕0轴缠绕"(更严格, 减少误判中阴而拦截的好买点)
|
| 504 |
+
# 'or' = 满足其一即算中阴(更宽松, 拦截更多)
|
| 505 |
+
'zhongyin_macd_mode': 'or', # 实测'or'最优: 累计收益+35pp(383%→418%), 胜率45.3%→46.8%
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
def __init__(self, df: pd.DataFrame):
|
| 509 |
+
self.df_raw = df.reset_index(drop=True)
|
| 510 |
+
self.close = self.df_raw['close']
|
| 511 |
+
self.dif, self.dea, self.macd_bar = calc_macd(self.close)
|
| 512 |
+
self.merged = merge_klines(self.df_raw)
|
| 513 |
+
self.fractals = find_fractals(self.merged)
|
| 514 |
+
self.bis = find_bis(self.fractals, min_k=self.MIN_BI_BARS)
|
| 515 |
+
self._bi_index = {id(b): k for k, b in enumerate(self.bis)}
|
| 516 |
+
self.segs = find_segs(self.bis)
|
| 517 |
+
self.pivots_all = find_pivots(self.bis, self.segs)
|
| 518 |
+
if self.CFG.get('drop_upgraded_pivots'):
|
| 519 |
+
last_upg_idx = -1
|
| 520 |
+
for k, p in enumerate(self.pivots_all):
|
| 521 |
+
if p.upgraded_level:
|
| 522 |
+
last_upg_idx = k
|
| 523 |
+
operative = [p for k, p in enumerate(self.pivots_all)
|
| 524 |
+
if k > last_upg_idx and not p.upgraded_level]
|
| 525 |
+
self.pivots = operative
|
| 526 |
+
else:
|
| 527 |
+
self.pivots = self.pivots_all
|
| 528 |
+
self.trend = classify_trend(self.pivots)
|
| 529 |
+
|
| 530 |
+
@property
|
| 531 |
+
def n_bis(self): return len(self.bis)
|
| 532 |
+
@property
|
| 533 |
+
def n_segs(self): return len(self.segs)
|
| 534 |
+
@property
|
| 535 |
+
def n_pivots(self): return len(self.pivots)
|
| 536 |
+
@property
|
| 537 |
+
def n_pivots_all(self): return len(self.pivots_all)
|
| 538 |
+
@property
|
| 539 |
+
def n_trend_pivots(self): return count_trend_pivots(self.pivots)
|
| 540 |
+
@property
|
| 541 |
+
def has_upgraded_pivot(self):
|
| 542 |
+
return any(p.upgraded_level for p in self.pivots_all)
|
| 543 |
+
|
| 544 |
+
@staticmethod
|
| 545 |
+
def _ds(ts):
|
| 546 |
+
ts = pd.Timestamp(ts)
|
| 547 |
+
if ts.hour == 0 and ts.minute == 0:
|
| 548 |
+
return ts.strftime('%Y-%m-%d')
|
| 549 |
+
return ts.strftime('%Y-%m-%d %H:%M')
|
| 550 |
+
|
| 551 |
+
def _validate_abc(self, direction: str):
|
| 552 |
+
if self.n_pivots < 2:
|
| 553 |
+
return None
|
| 554 |
+
last_piv = self.pivots[-1]
|
| 555 |
+
prev_piv = self.pivots[-2]
|
| 556 |
+
ratio = len(prev_piv.bis) / max(len(last_piv.bis), 1)
|
| 557 |
+
if not (1 / 3 <= ratio <= 3):
|
| 558 |
+
return None
|
| 559 |
+
a_start_idx = prev_piv.bis[0]
|
| 560 |
+
a_end_idx = last_piv.bis[0]
|
| 561 |
+
if a_end_idx <= a_start_idx:
|
| 562 |
+
return None
|
| 563 |
+
c_start_idx = last_piv.bis[-1] + 1
|
| 564 |
+
if c_start_idx >= self.n_bis:
|
| 565 |
+
return None
|
| 566 |
+
return {'a_start_idx': a_start_idx, 'a_end_idx': a_end_idx,
|
| 567 |
+
'b_pivot': last_piv, 'c_start_idx': c_start_idx}
|
| 568 |
+
|
| 569 |
+
def _validate_abc_consol(self, direction: str):
|
| 570 |
+
if self.n_pivots < 1:
|
| 571 |
+
return None
|
| 572 |
+
piv = self.pivots[-1]
|
| 573 |
+
a_end_idx = piv.bis[0]
|
| 574 |
+
if a_end_idx <= 0:
|
| 575 |
+
return None
|
| 576 |
+
c_start_idx = piv.bis[-1] + 1
|
| 577 |
+
if c_start_idx >= self.n_bis:
|
| 578 |
+
return None
|
| 579 |
+
want = 'down' if direction == 'down' else 'up'
|
| 580 |
+
a_start_idx = a_end_idx
|
| 581 |
+
for k in range(a_end_idx - 1, -1, -1):
|
| 582 |
+
a_start_idx = k
|
| 583 |
+
if k >= 1 and self.bis[k].direction != want and self.bis[k-1].direction != want:
|
| 584 |
+
a_start_idx = k + 1
|
| 585 |
+
break
|
| 586 |
+
if a_start_idx >= a_end_idx:
|
| 587 |
+
return None
|
| 588 |
+
return {'a_start_idx': a_start_idx, 'a_end_idx': a_end_idx,
|
| 589 |
+
'b_pivot': piv, 'c_start_idx': c_start_idx}
|
| 590 |
+
|
| 591 |
+
def _check_c_new_extreme(self, c_start_idx: int, direction: str):
|
| 592 |
+
want = 'up' if direction == 'up' else 'down'
|
| 593 |
+
c_bis = [self.bis[k] for k in range(c_start_idx, self.n_bis)
|
| 594 |
+
if self.bis[k].direction == want]
|
| 595 |
+
if not c_bis:
|
| 596 |
+
return False, None
|
| 597 |
+
last_piv = self.pivots[-1] if self.pivots else None
|
| 598 |
+
if direction == 'up':
|
| 599 |
+
c_ext = max(b.end.price for b in c_bis)
|
| 600 |
+
prior = (last_piv.gg if last_piv is not None
|
| 601 |
+
else max((b.high for b in self.bis[:c_start_idx]), default=0.0))
|
| 602 |
+
return c_ext > prior * (1 - 0.001), c_ext
|
| 603 |
+
else:
|
| 604 |
+
c_ext = min(b.end.price for b in c_bis)
|
| 605 |
+
prior = (last_piv.dd if last_piv is not None
|
| 606 |
+
else min((b.low for b in self.bis[:c_start_idx]), default=1e18))
|
| 607 |
+
return c_ext < prior * (1 + 0.001), c_ext
|
| 608 |
+
|
| 609 |
+
def _b_returns_to_zero(self, pivot) -> bool:
|
| 610 |
+
dates = self.df_raw['date']
|
| 611 |
+
mask = (dates >= pivot.start_date) & (dates <= pivot.end_date)
|
| 612 |
+
dif_b = self.dif[mask]
|
| 613 |
+
dea_b = self.dea[mask]
|
| 614 |
+
if len(dif_b) == 0 or len(dea_b) == 0:
|
| 615 |
+
return False
|
| 616 |
+
def near_zero(x):
|
| 617 |
+
if x.min() <= 0 <= x.max():
|
| 618 |
+
return True
|
| 619 |
+
return x.abs().min() < max(float(x.abs().max()), 1e-9) * 0.25
|
| 620 |
+
return near_zero(dif_b) and near_zero(dea_b)
|
| 621 |
+
|
| 622 |
+
def detect_double_pullback_to_zero(self, window: int = 40) -> bool:
|
| 623 |
+
n = len(self.dif)
|
| 624 |
+
if n < 10:
|
| 625 |
+
return False
|
| 626 |
+
dif = self.dif.iloc[-min(window, n):].reset_index(drop=True)
|
| 627 |
+
dea = self.dea.iloc[-min(window, n):].reset_index(drop=True)
|
| 628 |
+
hist = self.macd_bar.iloc[-min(window, n):].reset_index(drop=True)
|
| 629 |
+
scale = max(float(dif.abs().max()), float(dea.abs().max()), 1e-9)
|
| 630 |
+
near = scale * 0.25
|
| 631 |
+
zero_pulls = [i for i in range(len(dif))
|
| 632 |
+
if abs(float(dif.iloc[i])) <= near and abs(float(dea.iloc[i])) <= near]
|
| 633 |
+
if len(zero_pulls) < 2:
|
| 634 |
+
return False
|
| 635 |
+
def peak_between(left, right):
|
| 636 |
+
vals = dif.iloc[left + 1:right]
|
| 637 |
+
if len(vals) < 2:
|
| 638 |
+
return None
|
| 639 |
+
rel = int(vals.idxmax())
|
| 640 |
+
return float(dif.iloc[rel]), float(hist.iloc[max(left + 1, rel - 3):rel + 1].clip(lower=0).sum())
|
| 641 |
+
def trough_between(left, right):
|
| 642 |
+
vals = dif.iloc[left + 1:right]
|
| 643 |
+
if len(vals) < 2:
|
| 644 |
+
return None
|
| 645 |
+
rel = int(vals.idxmin())
|
| 646 |
+
return float(dif.iloc[rel]), float(hist.iloc[max(left + 1, rel - 3):rel + 1].clip(upper=0).abs().sum())
|
| 647 |
+
first_pull, second_pull = zero_pulls[-2], zero_pulls[-1]
|
| 648 |
+
p1, p2 = peak_between(first_pull, second_pull), peak_between(second_pull, len(dif))
|
| 649 |
+
if p1 and p2 and p1[0] > 0 and p2[0] > 0 and p2[0] < p1[0] and p2[1] <= p1[1]:
|
| 650 |
+
return True
|
| 651 |
+
t1, t2 = trough_between(first_pull, second_pull), trough_between(second_pull, len(dif))
|
| 652 |
+
if t1 and t2 and t1[0] < 0 and t2[0] < 0 and t2[0] > t1[0] and t2[1] <= t1[1]:
|
| 653 |
+
return True
|
| 654 |
+
return False
|
| 655 |
+
|
| 656 |
+
def divergence_strength_by_position(self) -> str:
|
| 657 |
+
if len(self.dif) < 5:
|
| 658 |
+
return 'strong_pullback'
|
| 659 |
+
dif_now = float(self.dif.iloc[-1])
|
| 660 |
+
dif_abs_max = float(self.dif.abs().max())
|
| 661 |
+
if dif_abs_max <= 1e-9:
|
| 662 |
+
return 'strong_pullback'
|
| 663 |
+
if abs(dif_now) >= dif_abs_max * 0.85:
|
| 664 |
+
return 'weak_pullback'
|
| 665 |
+
return 'strong_pullback'
|
| 666 |
+
|
| 667 |
+
def classify_post_divergence(self, direction: str) -> dict:
|
| 668 |
+
if not self.pivots or self.n_bis < 2:
|
| 669 |
+
return {'evolution': 'unknown', 'reason': '无中枢或笔不足'}
|
| 670 |
+
last_piv = self.pivots[-1]
|
| 671 |
+
after_idx = last_piv.bis[-1] + 1
|
| 672 |
+
def first_reversal_seg(want_dir):
|
| 673 |
+
for s in self.segs:
|
| 674 |
+
if not getattr(s, 'confirmed', True) or s.direction != want_dir:
|
| 675 |
+
continue
|
| 676 |
+
try:
|
| 677 |
+
first_idx = self._bi_index[id(s.bis[0])]
|
| 678 |
+
except KeyError:
|
| 679 |
+
continue
|
| 680 |
+
if first_idx >= after_idx:
|
| 681 |
+
return s
|
| 682 |
+
return None
|
| 683 |
+
if direction == 'down':
|
| 684 |
+
rebound = first_reversal_seg('up')
|
| 685 |
+
if rebound is None:
|
| 686 |
+
rebound = None
|
| 687 |
+
for b in self.bis[after_idx:]:
|
| 688 |
+
if b.direction == 'up':
|
| 689 |
+
rebound = b; break
|
| 690 |
+
if rebound is None:
|
| 691 |
+
return {'evolution': 'unknown', 'reason': '无反弹笔'}
|
| 692 |
+
if rebound.high < last_piv.zd:
|
| 693 |
+
return {'evolution': 'case1_extend',
|
| 694 |
+
'reason': f'反弹高{rebound.high:.3f}<最后中枢ZD{last_piv.zd:.3f} → 第29课情况①未回中枢(最弱,宜尽快撤)'}
|
| 695 |
+
if rebound.high >= last_piv.zd:
|
| 696 |
+
return {'evolution': 'case2_3_turn',
|
| 697 |
+
'reason': f'反弹回到中枢(高{rebound.high:.3f}≥ZD{last_piv.zd:.3f}) → 第29课情况②③转折(可持有等三买)'}
|
| 698 |
+
return {'evolution': 'case1_extend',
|
| 699 |
+
'reason': f'反弹未回中枢(高{rebound.high:.3f}<ZD{last_piv.zd:.3f}) → 偏向中枢扩展'}
|
| 700 |
+
else:
|
| 701 |
+
pullback = first_reversal_seg('down')
|
| 702 |
+
if pullback is None:
|
| 703 |
+
pullback = None
|
| 704 |
+
for b in self.bis[after_idx:]:
|
| 705 |
+
if b.direction == 'down':
|
| 706 |
+
pullback = b; break
|
| 707 |
+
if pullback is None:
|
| 708 |
+
return {'evolution': 'unknown', 'reason': '无回落笔'}
|
| 709 |
+
if pullback.low > last_piv.zg:
|
| 710 |
+
return {'evolution': 'case1_extend',
|
| 711 |
+
'reason': f'回落低{pullback.low:.3f}>最后中枢ZG{last_piv.zg:.3f} → 第29课情况①未回中枢(最弱)'}
|
| 712 |
+
if pullback.low <= last_piv.zg:
|
| 713 |
+
return {'evolution': 'case2_3_turn',
|
| 714 |
+
'reason': f'回落回到中枢(低{pullback.low:.3f}≤ZG{last_piv.zg:.3f}) → 第29课情况②③转折'}
|
| 715 |
+
return {'evolution': 'case1_extend',
|
| 716 |
+
'reason': f'回落未回中枢 → 偏向中枢扩展'}
|
| 717 |
+
|
| 718 |
+
def macd_wrap_zero(self, window: int = 15) -> bool:
|
| 719 |
+
"""L88-90: 中阴阶段的MACD特征 —— 黄白线(DIF/DEA)绕0轴缠绕。
|
| 720 |
+
近window根K线中, DIF与DEA的绝对值大多压在历史摆幅的25%以内即视为缠绕。"""
|
| 721 |
+
n = len(self.dif)
|
| 722 |
+
if n < window + 5:
|
| 723 |
+
return False
|
| 724 |
+
dif = self.dif.iloc[-window:]
|
| 725 |
+
dea = self.dea.iloc[-window:]
|
| 726 |
+
scale = max(float(self.dif.abs().tail(120).max()),
|
| 727 |
+
float(self.dea.abs().tail(120).max()), 1e-9)
|
| 728 |
+
near = scale * 0.25
|
| 729 |
+
frac = float(((dif.abs() <= near) & (dea.abs() <= near)).mean())
|
| 730 |
+
return frac >= 0.6
|
| 731 |
+
|
| 732 |
+
def macd_clarity(self, window: int = 60) -> dict:
|
| 733 |
+
"""L50: 本级别MACD的"清晰度" —— 柱子面积幅度 + 黄白线分离度, 归一化打分。
|
| 734 |
+
清晰度高的级别其背驰判定更可靠; 多级别联立时应优先采信清晰级别的MACD结论。"""
|
| 735 |
+
n = len(self.dif)
|
| 736 |
+
if n < 10:
|
| 737 |
+
return {'score': 0.0, 'label': '数据不足'}
|
| 738 |
+
w = min(window, n)
|
| 739 |
+
dif = self.dif.iloc[-w:]
|
| 740 |
+
dea = self.dea.iloc[-w:]
|
| 741 |
+
hist = self.macd_bar.iloc[-w:]
|
| 742 |
+
px = max(float(self.close.iloc[-1]), 1e-9)
|
| 743 |
+
bar_amp = float(hist.abs().mean()) / px # 柱子相对幅度
|
| 744 |
+
sep = float((dif - dea).abs().mean()) / px # 黄白线分离度
|
| 745 |
+
# 黄白线贴着0轴乱绕 → 不清晰
|
| 746 |
+
wrap_penalty = 0.5 if self.macd_wrap_zero() else 1.0
|
| 747 |
+
score = (bar_amp * 0.6 + sep * 0.4) * 1e3 * wrap_penalty
|
| 748 |
+
label = '清晰' if score >= 1.0 else ('一般' if score >= 0.4 else '模糊(黄白线/柱子贴0轴)')
|
| 749 |
+
return {'score': round(score, 3), 'label': label,
|
| 750 |
+
'bar_amp': round(bar_amp * 1e3, 3), 'sep': round(sep * 1e3, 3)}
|
| 751 |
+
|
| 752 |
+
def in_zhongyin(self) -> dict:
|
| 753 |
+
n = len(self.close)
|
| 754 |
+
if n < 20 or not self.pivots:
|
| 755 |
+
return {'in_zhongyin': False, 'boll_squeeze': False, 'reason': '数据不足'}
|
| 756 |
+
ma = self.close.rolling(20).mean()
|
| 757 |
+
sd = self.close.rolling(20).std()
|
| 758 |
+
if ma.iloc[-1] and ma.iloc[-1] > 0:
|
| 759 |
+
width = float((4 * sd.iloc[-1]) / ma.iloc[-1])
|
| 760 |
+
else:
|
| 761 |
+
width = 0.0
|
| 762 |
+
wseries = (4 * sd / ma).dropna().tail(60)
|
| 763 |
+
squeeze = bool(len(wseries) >= 20 and width <= wseries.quantile(0.30))
|
| 764 |
+
osc = self.zhongshu_oscillation_monitor()
|
| 765 |
+
macd_wrap = self.macd_wrap_zero()
|
| 766 |
+
dbl_pull = self.detect_double_pullback_to_zero()
|
| 767 |
+
mode = self.CFG.get('zhongyin_macd_mode', 'off')
|
| 768 |
+
if mode == 'and':
|
| 769 |
+
in_zy = (not osc.get('alert', False)) and squeeze and macd_wrap
|
| 770 |
+
elif mode == 'or':
|
| 771 |
+
in_zy = (not osc.get('alert', False)) and (squeeze or macd_wrap)
|
| 772 |
+
else:
|
| 773 |
+
in_zy = (not osc.get('alert', False)) and squeeze
|
| 774 |
+
reason = (f"BOLL带宽{width:.3f}{'(收口→中阴)' if squeeze else '(开口)'}; "
|
| 775 |
+
f"MACD黄白线{'绕0轴缠绕(L88-90中阴特征)' if macd_wrap else '已展开'}"
|
| 776 |
+
f"{'; 双回拉0轴(L89: 中阴结束转折预备)' if dbl_pull else ''}; {osc.get('reason','')}")
|
| 777 |
+
return {'in_zhongyin': in_zy, 'boll_squeeze': squeeze,
|
| 778 |
+
'macd_wrap_zero': macd_wrap, 'double_pullback_zero': dbl_pull,
|
| 779 |
+
'reason': reason}
|
| 780 |
+
|
| 781 |
+
def zhongshu_oscillation_monitor(self) -> dict:
|
| 782 |
+
if not self.pivots or self.n_bis < 1:
|
| 783 |
+
return {'alert': False, 'direction': '', 'reason': '无中枢'}
|
| 784 |
+
last_piv = self.pivots[-1]
|
| 785 |
+
cur = self.bis[-1]
|
| 786 |
+
if cur.low > last_piv.zg:
|
| 787 |
+
return {'alert': True, 'direction': 'up',
|
| 788 |
+
'reason': f'第92课: 末笔({cur.low:.3f}~{cur.high:.3f})已离开中枢上沿ZG{last_piv.zg:.3f} → 向上变盘预警'}
|
| 789 |
+
if cur.high < last_piv.zd:
|
| 790 |
+
return {'alert': True, 'direction': 'down',
|
| 791 |
+
'reason': f'第92课: 末笔({cur.low:.3f}~{cur.high:.3f})已离开中枢下沿ZD{last_piv.zd:.3f} → 向下变盘预警'}
|
| 792 |
+
return {'alert': False, 'direction': '', 'reason': '末笔仍在中枢区间内, 中枢震荡延续'}
|
| 793 |
+
|
| 794 |
+
def bottom_construction_state(self) -> str:
|
| 795 |
+
has_b1 = self.detect_b1() is not None
|
| 796 |
+
has_b3 = self.detect_b3() is not None
|
| 797 |
+
has_s3 = self.detect_s3() is not None
|
| 798 |
+
if has_s3:
|
| 799 |
+
return 'failed'
|
| 800 |
+
if has_b3:
|
| 801 |
+
return 'completed'
|
| 802 |
+
if has_b1:
|
| 803 |
+
return 'constructing'
|
| 804 |
+
return 'none'
|
| 805 |
+
|
| 806 |
+
def _seg_index_range(self, seg):
|
| 807 |
+
m = self._bi_index
|
| 808 |
+
try:
|
| 809 |
+
return m[id(seg.bis[0])], m[id(seg.bis[-1])]
|
| 810 |
+
except KeyError:
|
| 811 |
+
return None
|
| 812 |
+
|
| 813 |
+
def _bi_exit_pullback_fallback(self, pivot, exit_dir: str, pull_dir: str):
|
| 814 |
+
pe = pivot.bis[-1]
|
| 815 |
+
leave = pull = None
|
| 816 |
+
for k in range(pe + 1, self.n_bis):
|
| 817 |
+
b = self.bis[k]
|
| 818 |
+
if leave is None:
|
| 819 |
+
if b.direction == exit_dir:
|
| 820 |
+
leave = b
|
| 821 |
+
continue
|
| 822 |
+
if b.direction == pull_dir:
|
| 823 |
+
pull = b
|
| 824 |
+
break
|
| 825 |
+
if leave is None or pull is None:
|
| 826 |
+
return None
|
| 827 |
+
return leave, pull
|
| 828 |
+
|
| 829 |
+
def _last_exit_pullback_segments(self, pivot, exit_dir: str, pull_dir: str):
|
| 830 |
+
pivot_end = pivot.bis[-1]
|
| 831 |
+
seq = []
|
| 832 |
+
for s in (self.segs or []):
|
| 833 |
+
if not getattr(s, 'confirmed', True):
|
| 834 |
+
continue
|
| 835 |
+
rng = self._seg_index_range(s)
|
| 836 |
+
if rng is None:
|
| 837 |
+
continue
|
| 838 |
+
first_idx, last_idx = rng
|
| 839 |
+
if last_idx < pivot_end:
|
| 840 |
+
continue
|
| 841 |
+
seq.append((s, first_idx, last_idx))
|
| 842 |
+
first_only = self.CFG.get('b3s3_first_pullback_only')
|
| 843 |
+
if first_only:
|
| 844 |
+
for i in range(len(seq) - 1):
|
| 845 |
+
leave, lf, _ = seq[i]
|
| 846 |
+
pull = seq[i + 1][0]
|
| 847 |
+
if leave.direction == exit_dir and pull.direction == pull_dir and lf >= pivot_end:
|
| 848 |
+
return leave, pull
|
| 849 |
+
else:
|
| 850 |
+
for i in range(len(seq) - 1, 0, -1):
|
| 851 |
+
pull, _, _ = seq[i]
|
| 852 |
+
leave, _, _ = seq[i - 1]
|
| 853 |
+
if leave.direction == exit_dir and pull.direction == pull_dir:
|
| 854 |
+
return leave, pull
|
| 855 |
+
return self._bi_exit_pullback_fallback(pivot, exit_dir, pull_dir)
|
| 856 |
+
|
| 857 |
+
def assess_divergence(self, a_start, a_end, c_start, c_end, direction: str) -> DivergenceGrade:
|
| 858 |
+
dates = self.df_raw['date']
|
| 859 |
+
n_tp = self.n_trend_pivots
|
| 860 |
+
is_trend_div = n_tp >= 2
|
| 861 |
+
a_area = macd_area_between(a_start, a_end, self.macd_bar, dates, direction)
|
| 862 |
+
c_area = macd_area_between(c_start, c_end, self.macd_bar, dates, direction)
|
| 863 |
+
if a_area <= 1e-9:
|
| 864 |
+
return DivergenceGrade('NONE', False, False, 0.0, a_area, c_area, 0.0, 0.0,
|
| 865 |
+
direction, 'A段MACD面积为0,无可比基准',
|
| 866 |
+
is_trend_divergence=is_trend_div, n_trend_pivots=n_tp)
|
| 867 |
+
ratio = c_area / a_area
|
| 868 |
+
area_ok = ratio < self.DIVERGE_RATIO
|
| 869 |
+
TOL = self.DIF_TOLERANCE
|
| 870 |
+
if direction == 'up':
|
| 871 |
+
a_dif = dif_extreme_in(a_start, a_end, self.dif, dates, 'peak')
|
| 872 |
+
c_dif = dif_extreme_in(c_start, c_end, self.dif, dates, 'peak')
|
| 873 |
+
dif_ok = c_dif < a_dif * (1 - TOL)
|
| 874 |
+
else:
|
| 875 |
+
a_dif = dif_extreme_in(a_start, a_end, self.dif, dates, 'trough')
|
| 876 |
+
c_dif = dif_extreme_in(c_start, c_end, self.dif, dates, 'trough')
|
| 877 |
+
dif_ok = c_dif > a_dif * (1 - TOL)
|
| 878 |
+
ext_label = 'DIF峰' if direction == 'up' else 'DIF谷'
|
| 879 |
+
div_kind = '趋势背驰' if is_trend_div else '盘整背驰'
|
| 880 |
+
if area_ok and dif_ok:
|
| 881 |
+
grade = 'STRONG'
|
| 882 |
+
reason = f'标准{div_kind}(面积+DIF均满足)|A面积:{a_area:.4f} C面积:{c_area:.4f}(比值{ratio:.1%}<{self.DIVERGE_RATIO:.0%})|{ext_label} A:{a_dif:.4f} C:{c_dif:.4f}|{n_tp}中枢'
|
| 883 |
+
elif area_ok and not dif_ok:
|
| 884 |
+
grade = 'WEAK'
|
| 885 |
+
reason = f'{div_kind}信号(面积触发)|A面积:{a_area:.4f} C面积:{c_area:.4f}(比值{ratio:.1%}<{self.DIVERGE_RATIO:.0%})|{n_tp}中枢'
|
| 886 |
+
elif dif_ok and not area_ok:
|
| 887 |
+
grade = 'WEAK'
|
| 888 |
+
reason = f'{div_kind}信号(DIF触发)|{ext_label} A:{a_dif:.4f} C:{c_dif:.4f}|{n_tp}中枢'
|
| 889 |
+
else:
|
| 890 |
+
grade = 'NONE'
|
| 891 |
+
reason = f'无背驰(两判据均不满足)|面积比{ratio:.1%}>={self.DIVERGE_RATIO:.0%}|{ext_label} A:{a_dif:.4f} C:{c_dif:.4f}'
|
| 892 |
+
return DivergenceGrade(grade, area_ok, dif_ok, ratio, a_area, c_area, a_dif, c_dif,
|
| 893 |
+
direction, reason, is_trend_divergence=is_trend_div, n_trend_pivots=n_tp)
|
| 894 |
+
|
| 895 |
+
def _find_prev_b1(self) -> tuple:
|
| 896 |
+
if not self.pivots:
|
| 897 |
+
return (None, '')
|
| 898 |
+
last_piv = self.pivots[-1]
|
| 899 |
+
pivot_first_bi_idx = last_piv.bis[0]
|
| 900 |
+
if pivot_first_bi_idx <= 0:
|
| 901 |
+
return (None, '')
|
| 902 |
+
downs = []
|
| 903 |
+
for k in range(pivot_first_bi_idx - 1, -1, -1):
|
| 904 |
+
b = self.bis[k]
|
| 905 |
+
downs.append(b)
|
| 906 |
+
if b.direction == 'up' and k - 1 >= 0 and self.bis[k-1].direction == 'down':
|
| 907 |
+
if len(downs) >= 4:
|
| 908 |
+
break
|
| 909 |
+
down_bis = [b for b in downs if b.direction == 'down']
|
| 910 |
+
if not down_bis:
|
| 911 |
+
return (None, '')
|
| 912 |
+
b1 = min(down_bis, key=lambda b: b.end.price)
|
| 913 |
+
return (b1.end.price, self._ds(b1.end.date))
|
| 914 |
+
|
| 915 |
+
def _find_prev_b2(self) -> tuple:
|
| 916 |
+
b1_price, b1_date_str = self._find_prev_b1()
|
| 917 |
+
if b1_price is None or not self.pivots:
|
| 918 |
+
return (None, '')
|
| 919 |
+
b1_date = pd.Timestamp(b1_date_str)
|
| 920 |
+
last_piv = self.pivots[-1]
|
| 921 |
+
right_bi_idx = last_piv.bis[-1] + 1
|
| 922 |
+
for b in self.bis[:right_bi_idx]:
|
| 923 |
+
if b.direction != 'down':
|
| 924 |
+
continue
|
| 925 |
+
if b.end.date <= b1_date:
|
| 926 |
+
continue
|
| 927 |
+
if b.end.price > b1_price + 1e-9:
|
| 928 |
+
return (b.end.price, self._ds(b.end.date))
|
| 929 |
+
return (None, '')
|
| 930 |
+
|
| 931 |
+
def _find_prev_s1(self) -> tuple:
|
| 932 |
+
if not self.pivots:
|
| 933 |
+
return (None, '')
|
| 934 |
+
last_piv = self.pivots[-1]
|
| 935 |
+
pivot_first_bi_idx = last_piv.bis[0]
|
| 936 |
+
if pivot_first_bi_idx <= 0:
|
| 937 |
+
return (None, '')
|
| 938 |
+
ups = []
|
| 939 |
+
for k in range(pivot_first_bi_idx - 1, -1, -1):
|
| 940 |
+
b = self.bis[k]
|
| 941 |
+
ups.append(b)
|
| 942 |
+
if b.direction == 'down' and k - 1 >= 0 and self.bis[k-1].direction == 'up':
|
| 943 |
+
if len(ups) >= 4:
|
| 944 |
+
break
|
| 945 |
+
up_bis = [b for b in ups if b.direction == 'up']
|
| 946 |
+
if not up_bis:
|
| 947 |
+
return (None, '')
|
| 948 |
+
s1 = max(up_bis, key=lambda b: b.end.price)
|
| 949 |
+
return (s1.end.price, self._ds(s1.end.date))
|
| 950 |
+
|
| 951 |
+
def _find_prev_s2(self) -> tuple:
|
| 952 |
+
s1_price, s1_date_str = self._find_prev_s1()
|
| 953 |
+
if s1_price is None or not self.pivots:
|
| 954 |
+
return (None, '')
|
| 955 |
+
s1_date = pd.Timestamp(s1_date_str)
|
| 956 |
+
last_piv = self.pivots[-1]
|
| 957 |
+
right_bi_idx = last_piv.bis[-1] + 1
|
| 958 |
+
for b in self.bis[:right_bi_idx]:
|
| 959 |
+
if b.direction != 'up':
|
| 960 |
+
continue
|
| 961 |
+
if b.end.date <= s1_date:
|
| 962 |
+
continue
|
| 963 |
+
if b.end.price < s1_price - 1e-9:
|
| 964 |
+
return (b.end.price, self._ds(b.end.date))
|
| 965 |
+
return (None, '')
|
| 966 |
+
|
| 967 |
+
def detect_b1(self) -> Optional[Signal]:
|
| 968 |
+
if self.n_bis < 5:
|
| 969 |
+
return None
|
| 970 |
+
cur = self.bis[-1]
|
| 971 |
+
if cur.direction != 'down':
|
| 972 |
+
return None
|
| 973 |
+
dif_now = float(self.dif.iloc[-1])
|
| 974 |
+
if dif_now >= 0:
|
| 975 |
+
return None
|
| 976 |
+
trend_ok = (self.n_pivots >= 2 and self.trend == 'down_trend')
|
| 977 |
+
consol_ok = (self.CFG.get('b1_allow_consol_diverge') and self.n_pivots >= 1)
|
| 978 |
+
if not (trend_ok or consol_ok):
|
| 979 |
+
return None
|
| 980 |
+
abc = self._validate_abc('down')
|
| 981 |
+
if abc is None and consol_ok:
|
| 982 |
+
abc = self._validate_abc_consol('down')
|
| 983 |
+
if abc is None:
|
| 984 |
+
return None
|
| 985 |
+
c_ok, c_low = self._check_c_new_extreme(abc['c_start_idx'], 'down')
|
| 986 |
+
if not c_ok:
|
| 987 |
+
return None
|
| 988 |
+
last_piv = self.pivots[-1]
|
| 989 |
+
a_down = [self.bis[k] for k in range(abc['a_start_idx'], abc['a_end_idx'])
|
| 990 |
+
if self.bis[k].direction == 'down']
|
| 991 |
+
c_down = [self.bis[k] for k in range(abc['c_start_idx'], self.n_bis)
|
| 992 |
+
if self.bis[k].direction == 'down']
|
| 993 |
+
if not a_down or not c_down:
|
| 994 |
+
return None
|
| 995 |
+
dg = self.assess_divergence(a_down[0].start.date, a_down[-1].end.date,
|
| 996 |
+
c_down[0].start.date, c_down[-1].end.date, 'down')
|
| 997 |
+
if dg.grade == 'NONE':
|
| 998 |
+
return None
|
| 999 |
+
div_kind = '趋势底背驰' if dg.is_trend_divergence else '盘整底背驰(第27课)'
|
| 1000 |
+
b_zero = self._b_returns_to_zero(last_piv)
|
| 1001 |
+
dbl_pull = self.detect_double_pullback_to_zero()
|
| 1002 |
+
pos_strength = self.divergence_strength_by_position()
|
| 1003 |
+
post_evo = self.classify_post_divergence('down')
|
| 1004 |
+
a_low_bi = min(a_down, key=lambda b: b.end.price)
|
| 1005 |
+
c_low_bi = min(c_down, key=lambda b: b.end.price)
|
| 1006 |
+
return Signal(kind='B1', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]),
|
| 1007 |
+
reason=f'{div_kind}一买|{self.n_pivots}中枢|ABC三段{"+B回0轴" if b_zero else ""}|{dg.reason}|DIF={dif_now:.4f}<0',
|
| 1008 |
+
pivot_zg=last_piv.zg, pivot_zd=last_piv.zd, macd_ratio=dg.area_ratio, dif_value=dif_now,
|
| 1009 |
+
n_pivots=self.n_pivots, trend=self.trend,
|
| 1010 |
+
extras={'a_seg': (a_down[0].start.date, a_down[-1].end.date, a_down[0].start.price, a_down[-1].end.price),
|
| 1011 |
+
'diverge_grade': dg.grade,
|
| 1012 |
+
'a_low': float(a_low_bi.end.price),
|
| 1013 |
+
'a_low_date': self._ds(a_low_bi.end.date),
|
| 1014 |
+
'b1_price': float(cur.end.price),
|
| 1015 |
+
'b1_date': self._ds(cur.end.date),
|
| 1016 |
+
'macd_grade': dg.grade,
|
| 1017 |
+
'macd_area_ratio': dg.area_ratio,
|
| 1018 |
+
'dif_ok': dg.dif_ok,
|
| 1019 |
+
'area_ok': dg.area_ok,
|
| 1020 |
+
'b_returns_zero': b_zero,
|
| 1021 |
+
'double_pullback': dbl_pull,
|
| 1022 |
+
'pos_strength': pos_strength,
|
| 1023 |
+
'post_evolution': post_evo['evolution'],
|
| 1024 |
+
'c_new_low': c_low,
|
| 1025 |
+
'c_new_low_date': self._ds(c_low_bi.end.date),
|
| 1026 |
+
'n_trend_pivots': self.n_trend_pivots,
|
| 1027 |
+
'price_date': self._ds(cur.end.date),
|
| 1028 |
+
'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
|
| 1029 |
+
'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
|
| 1030 |
+
'pivot_start_date': self._ds(last_piv.start_date),
|
| 1031 |
+
'pivot_end_date': self._ds(last_piv.end_date)},
|
| 1032 |
+
diverge_grade=dg)
|
| 1033 |
+
|
| 1034 |
+
def detect_b2(self) -> Optional[Signal]:
|
| 1035 |
+
if self.n_bis < 4:
|
| 1036 |
+
return None
|
| 1037 |
+
cur = self.bis[-1]
|
| 1038 |
+
if cur.direction != 'down':
|
| 1039 |
+
return None
|
| 1040 |
+
prev_downs = [b for b in self.bis[:-1] if b.direction == 'down']
|
| 1041 |
+
if not prev_downs:
|
| 1042 |
+
return None
|
| 1043 |
+
prev = prev_downs[-1]
|
| 1044 |
+
if not (cur.low >= prev.low and cur.end.price > prev.end.price):
|
| 1045 |
+
return None
|
| 1046 |
+
cur_price = float(self.close.iloc[-1])
|
| 1047 |
+
if cur_price < cur.end.price * (1 - self.PIVOT_TOLERANCE):
|
| 1048 |
+
return None
|
| 1049 |
+
if self.CFG.get('b2s2_anchor_to_first'):
|
| 1050 |
+
b1_anchor, _ = self._find_prev_b1()
|
| 1051 |
+
if b1_anchor is None:
|
| 1052 |
+
return None
|
| 1053 |
+
if cur.end.price < b1_anchor - 1e-9:
|
| 1054 |
+
return None
|
| 1055 |
+
dif_now = float(self.dif.iloc[-1])
|
| 1056 |
+
if self.CFG.get('b2_macd_zero_pullback'):
|
| 1057 |
+
look = self.dif.tail(12)
|
| 1058 |
+
crossed_up = bool((look > 0).any())
|
| 1059 |
+
if not crossed_up:
|
| 1060 |
+
return None
|
| 1061 |
+
if dif_now < -self.DIF_TOLERANCE:
|
| 1062 |
+
return None
|
| 1063 |
+
last_piv = self.pivots[-1] if self.pivots else None
|
| 1064 |
+
b1_price, b1_date = prev.end.price, self._ds(prev.end.date)
|
| 1065 |
+
return Signal(kind='B2', date=self.df_raw['date'].iloc[-1], price=cur_price,
|
| 1066 |
+
reason=f'二买:一买后回踩不破|当前低{cur.end.price:.3f}>一买{b1_price:.3f}|二买不以背驰为成立条件(第21课)',
|
| 1067 |
+
pivot_zg=None, pivot_zd=None,
|
| 1068 |
+
macd_ratio=None, dif_value=dif_now, n_pivots=self.n_pivots, trend=self.trend,
|
| 1069 |
+
extras={'prev_low': prev.end.price, 'cur_low': cur.end.price,
|
| 1070 |
+
'cur_low_date': self._ds(cur.end.date),
|
| 1071 |
+
'prev_low_date': self._ds(prev.end.date),
|
| 1072 |
+
'b1_price': b1_price,
|
| 1073 |
+
'b1_date': b1_date,
|
| 1074 |
+
'price_date': self._ds(cur.end.date),
|
| 1075 |
+
'context_pivot_zg': last_piv.zg if last_piv else None,
|
| 1076 |
+
'context_pivot_zd': last_piv.zd if last_piv else None,
|
| 1077 |
+
'context_pivot_zg_date': self._ds(last_piv.zg_date) if last_piv and last_piv.zg_date else '',
|
| 1078 |
+
'context_pivot_zd_date': self._ds(last_piv.zd_date) if last_piv and last_piv.zd_date else '',
|
| 1079 |
+
'context_pivot_start_date': self._ds(last_piv.start_date) if last_piv else '',
|
| 1080 |
+
'context_pivot_end_date': self._ds(last_piv.end_date) if last_piv else ''},
|
| 1081 |
+
diverge_grade=None)
|
| 1082 |
+
|
| 1083 |
+
def detect_b3(self) -> Optional[Signal]:
|
| 1084 |
+
if self.n_bis < 5 or self.n_pivots < 1:
|
| 1085 |
+
return None
|
| 1086 |
+
late_trend_b3 = self.n_trend_pivots >= 2
|
| 1087 |
+
last_piv = self.pivots[-1]
|
| 1088 |
+
zg, zd = last_piv.zg, last_piv.zd
|
| 1089 |
+
piv_height = zg - zd
|
| 1090 |
+
cur = self.bis[-1]
|
| 1091 |
+
if cur.direction != 'up':
|
| 1092 |
+
return None
|
| 1093 |
+
pair = self._last_exit_pullback_segments(last_piv, 'up', 'down')
|
| 1094 |
+
if pair is None:
|
| 1095 |
+
return None
|
| 1096 |
+
exit_seg, pull_seg = pair
|
| 1097 |
+
if not (exit_seg.low <= zg * (1 + self.PIVOT_TOLERANCE) and exit_seg.high > zg):
|
| 1098 |
+
return None
|
| 1099 |
+
if pull_seg.low < zg * (1 - self.PIVOT_TOLERANCE):
|
| 1100 |
+
return None
|
| 1101 |
+
leaves_pivot = pull_seg.low >= zg
|
| 1102 |
+
cur_price = float(self.close.iloc[-1])
|
| 1103 |
+
if cur_price <= zg:
|
| 1104 |
+
return None
|
| 1105 |
+
ex_amp = exit_seg.high - exit_seg.low
|
| 1106 |
+
if piv_height > 0 and ex_amp < piv_height * 0.5:
|
| 1107 |
+
return None
|
| 1108 |
+
if len(last_piv.bis) < 3:
|
| 1109 |
+
return None
|
| 1110 |
+
confirm_txt = '回踩离枢确认(新中枢生成)' if leaves_pivot else '回踩贴ZG(容差内,新中枢待确认)'
|
| 1111 |
+
b1_price, b1_date = self._find_prev_b1()
|
| 1112 |
+
b2_price, b2_date = self._find_prev_b2()
|
| 1113 |
+
warn_txt = '|第二个以上同向中枢,实盘宜改用低级别一买' if late_trend_b3 else ''
|
| 1114 |
+
return Signal(kind='B3', date=self.df_raw['date'].iloc[-1], price=cur_price,
|
| 1115 |
+
reason=f'标准三买|ZG={zg:.3f},ZD={zd:.3f}|线段离枢:{exit_seg.low:.3f}→{exit_seg.high:.3f}(幅度{ex_amp:.3f})|线段回试低{pull_seg.low:.3f}|{confirm_txt}|当前{cur_price:.3f}>ZG{warn_txt}',
|
| 1116 |
+
pivot_zg=zg, pivot_zd=zd, macd_ratio=None, dif_value=float(self.dif.iloc[-1]),
|
| 1117 |
+
n_pivots=self.n_pivots, trend=self.trend,
|
| 1118 |
+
extras={'exit_seg': (exit_seg.low, exit_seg.high),
|
| 1119 |
+
'pull_low': pull_seg.low,
|
| 1120 |
+
'pull_low_date': self._ds(pull_seg.end.date),
|
| 1121 |
+
'exit_start_date': self._ds(exit_seg.start.date),
|
| 1122 |
+
'exit_end_date': self._ds(exit_seg.end.date),
|
| 1123 |
+
'b1_price': b1_price,
|
| 1124 |
+
'b1_date': b1_date,
|
| 1125 |
+
'b2_price': b2_price,
|
| 1126 |
+
'b2_date': b2_date,
|
| 1127 |
+
'piv_bi_count': len(last_piv.bis), 'leaves_pivot': leaves_pivot,
|
| 1128 |
+
'late_trend_b3': late_trend_b3,
|
| 1129 |
+
'price_date': self._ds(self.df_raw['date'].iloc[-1]),
|
| 1130 |
+
'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
|
| 1131 |
+
'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
|
| 1132 |
+
'pivot_start_date': self._ds(last_piv.start_date),
|
| 1133 |
+
'pivot_end_date': self._ds(last_piv.end_date)},
|
| 1134 |
+
diverge_grade=None)
|
| 1135 |
+
|
| 1136 |
+
def detect_s1(self) -> Optional[Signal]:
|
| 1137 |
+
if self.n_bis < 5:
|
| 1138 |
+
return None
|
| 1139 |
+
cur = self.bis[-1]
|
| 1140 |
+
if cur.direction != 'up':
|
| 1141 |
+
return None
|
| 1142 |
+
trend_ok = (self.n_pivots >= 2 and self.trend == 'up_trend')
|
| 1143 |
+
consol_ok = (self.CFG.get('b1_allow_consol_diverge') and self.n_pivots >= 1)
|
| 1144 |
+
if not (trend_ok or consol_ok):
|
| 1145 |
+
return None
|
| 1146 |
+
abc = self._validate_abc('up')
|
| 1147 |
+
if abc is None and consol_ok:
|
| 1148 |
+
abc = self._validate_abc_consol('up')
|
| 1149 |
+
if abc is None:
|
| 1150 |
+
return None
|
| 1151 |
+
last_piv = self.pivots[-1]
|
| 1152 |
+
a_start_idx = abc['a_start_idx']; a_end_idx = abc['a_end_idx']
|
| 1153 |
+
if a_end_idx <= a_start_idx:
|
| 1154 |
+
return None
|
| 1155 |
+
a_up_bis = [self.bis[k] for k in range(a_start_idx, a_end_idx) if self.bis[k].direction == 'up']
|
| 1156 |
+
if not a_up_bis:
|
| 1157 |
+
return None
|
| 1158 |
+
a_high = max(b.end.price for b in a_up_bis)
|
| 1159 |
+
c_start_idx = last_piv.bis[-1] + 1
|
| 1160 |
+
c_up_bis = [self.bis[k] for k in range(c_start_idx, self.n_bis) if self.bis[k].direction == 'up']
|
| 1161 |
+
if not c_up_bis:
|
| 1162 |
+
return None
|
| 1163 |
+
c_high = max(b.end.price for b in c_up_bis)
|
| 1164 |
+
if c_high <= a_high:
|
| 1165 |
+
return None
|
| 1166 |
+
a_high_bi = max(a_up_bis, key=lambda b: b.end.price)
|
| 1167 |
+
dg = self.assess_divergence(a_up_bis[0].start.date, a_up_bis[-1].end.date,
|
| 1168 |
+
c_up_bis[0].start.date, c_up_bis[-1].end.date, 'up')
|
| 1169 |
+
if dg.grade == 'NONE':
|
| 1170 |
+
return None
|
| 1171 |
+
b_zero = self._b_returns_to_zero(last_piv)
|
| 1172 |
+
dbl_pull = self.detect_double_pullback_to_zero()
|
| 1173 |
+
pos_strength = self.divergence_strength_by_position()
|
| 1174 |
+
post_evo = self.classify_post_divergence('up')
|
| 1175 |
+
dif_now = float(self.dif.iloc[-1])
|
| 1176 |
+
c_high_bi = max(c_up_bis, key=lambda b: b.end.price)
|
| 1177 |
+
return Signal(kind='S1', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]),
|
| 1178 |
+
reason=f'一卖|上涨趋势{self.n_pivots}中枢|价创新高C{c_high:.3f}>A段高{a_high:.3f}{"+B回0轴" if b_zero else ""}|{dg.reason}',
|
| 1179 |
+
pivot_zg=last_piv.zg, pivot_zd=last_piv.zd, macd_ratio=dg.area_ratio, dif_value=dif_now,
|
| 1180 |
+
n_pivots=self.n_pivots, trend=self.trend,
|
| 1181 |
+
extras={'a_high': a_high, 'c_high': c_high, 'a_area': dg.a_area, 'c_area': dg.c_area,
|
| 1182 |
+
'diverge_grade': dg.grade,
|
| 1183 |
+
'a_high_date': self._ds(a_high_bi.end.date),
|
| 1184 |
+
'b_returns_zero': b_zero,
|
| 1185 |
+
'double_pullback': dbl_pull,
|
| 1186 |
+
'pos_strength': pos_strength,
|
| 1187 |
+
'post_evolution': post_evo['evolution'],
|
| 1188 |
+
'c_high_date': self._ds(c_high_bi.end.date),
|
| 1189 |
+
'price_date': self._ds(cur.end.date),
|
| 1190 |
+
'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
|
| 1191 |
+
'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
|
| 1192 |
+
'pivot_start_date': self._ds(last_piv.start_date),
|
| 1193 |
+
'pivot_end_date': self._ds(last_piv.end_date)},
|
| 1194 |
+
diverge_grade=dg)
|
| 1195 |
+
|
| 1196 |
+
def detect_s2(self) -> Optional[Signal]:
|
| 1197 |
+
if self.n_bis < 4:
|
| 1198 |
+
return None
|
| 1199 |
+
cur = self.bis[-1]
|
| 1200 |
+
if cur.direction != 'up':
|
| 1201 |
+
return None
|
| 1202 |
+
prev_ups = [b for b in self.bis[:-1] if b.direction == 'up']
|
| 1203 |
+
if not prev_ups:
|
| 1204 |
+
return None
|
| 1205 |
+
prev = prev_ups[-1]
|
| 1206 |
+
if cur.high < prev.high and cur.end.price < prev.end.price:
|
| 1207 |
+
cur_price = float(self.close.iloc[-1])
|
| 1208 |
+
if cur_price > cur.end.price * (1 + self.PIVOT_TOLERANCE):
|
| 1209 |
+
return None
|
| 1210 |
+
if self.CFG.get('b2s2_anchor_to_first'):
|
| 1211 |
+
s1_anchor, _ = self._find_prev_s1()
|
| 1212 |
+
if s1_anchor is None:
|
| 1213 |
+
return None
|
| 1214 |
+
if cur.end.price > s1_anchor + 1e-9:
|
| 1215 |
+
return None
|
| 1216 |
+
dif_now = float(self.dif.iloc[-1])
|
| 1217 |
+
last_piv = self.pivots[-1] if self.pivots else None
|
| 1218 |
+
s1_price, s1_date = prev.end.price, self._ds(prev.end.date)
|
| 1219 |
+
return Signal(kind='S2', date=self.df_raw['date'].iloc[-1], price=cur_price,
|
| 1220 |
+
reason=f'二卖:一卖后反弹不破|当前高{cur.end.price:.3f}<一卖{s1_price:.3f}|二卖不以背驰为成立条件(第21课)',
|
| 1221 |
+
pivot_zg=None, pivot_zd=None,
|
| 1222 |
+
dif_value=dif_now, n_pivots=self.n_pivots, trend=self.trend,
|
| 1223 |
+
extras={'prev_high': prev.end.price, 'cur_high': cur.end.price,
|
| 1224 |
+
'cur_high_date': self._ds(cur.end.date),
|
| 1225 |
+
'prev_high_date': self._ds(prev.end.date),
|
| 1226 |
+
's1_price': s1_price, 's1_date': s1_date,
|
| 1227 |
+
'price_date': self._ds(cur.end.date),
|
| 1228 |
+
'context_pivot_zg': last_piv.zg if last_piv else None,
|
| 1229 |
+
'context_pivot_zd': last_piv.zd if last_piv else None,
|
| 1230 |
+
'context_pivot_zg_date': self._ds(last_piv.zg_date) if last_piv and last_piv.zg_date else '',
|
| 1231 |
+
'context_pivot_zd_date': self._ds(last_piv.zd_date) if last_piv and last_piv.zd_date else '',
|
| 1232 |
+
'context_pivot_start_date': self._ds(last_piv.start_date) if last_piv else '',
|
| 1233 |
+
'context_pivot_end_date': self._ds(last_piv.end_date) if last_piv else ''},
|
| 1234 |
+
diverge_grade=None)
|
| 1235 |
+
return None
|
| 1236 |
+
|
| 1237 |
+
def detect_s3(self) -> Optional[Signal]:
|
| 1238 |
+
if self.n_bis < 5 or self.n_pivots < 1:
|
| 1239 |
+
return None
|
| 1240 |
+
last_piv = self.pivots[-1]
|
| 1241 |
+
zg, zd = last_piv.zg, last_piv.zd
|
| 1242 |
+
if len(self.bis) < 3:
|
| 1243 |
+
return None
|
| 1244 |
+
cur = self.bis[-1]
|
| 1245 |
+
if cur.direction != 'down':
|
| 1246 |
+
return None
|
| 1247 |
+
s1_price, s1_date = self._find_prev_s1()
|
| 1248 |
+
s2_price, s2_date = self._find_prev_s2()
|
| 1249 |
+
base_dates = {'price_date': self._ds(self.df_raw['date'].iloc[-1]),
|
| 1250 |
+
's1_price': s1_price, 's1_date': s1_date,
|
| 1251 |
+
's2_price': s2_price, 's2_date': s2_date,
|
| 1252 |
+
'pivot_zg_date': self._ds(last_piv.zg_date) if last_piv.zg_date else '',
|
| 1253 |
+
'pivot_zd_date': self._ds(last_piv.zd_date) if last_piv.zd_date else '',
|
| 1254 |
+
'pivot_start_date': self._ds(last_piv.start_date),
|
| 1255 |
+
'pivot_end_date': self._ds(last_piv.end_date)}
|
| 1256 |
+
pair = self._last_exit_pullback_segments(last_piv, 'down', 'up')
|
| 1257 |
+
if pair is None:
|
| 1258 |
+
return None
|
| 1259 |
+
exit_seg, pull_seg = pair
|
| 1260 |
+
if not (exit_seg.high >= zd * (1 - self.PIVOT_TOLERANCE) and exit_seg.low < zd):
|
| 1261 |
+
return None
|
| 1262 |
+
if pull_seg.high > zd * (1 + self.PIVOT_TOLERANCE):
|
| 1263 |
+
return None
|
| 1264 |
+
if float(self.close.iloc[-1]) >= zd:
|
| 1265 |
+
return None
|
| 1266 |
+
return Signal(kind='S3', date=self.df_raw['date'].iloc[-1], price=float(self.close.iloc[-1]),
|
| 1267 |
+
reason=f'标准三卖|ZD={zd:.3f}|线段离枢低{exit_seg.low:.3f}<ZD|线段回抽高{pull_seg.high:.3f}未过ZD',
|
| 1268 |
+
pivot_zg=zg, pivot_zd=zd, dif_value=float(self.dif.iloc[-1]),
|
| 1269 |
+
n_pivots=self.n_pivots, trend=self.trend, extras=base_dates, diverge_grade=None)
|
| 1270 |
+
|
| 1271 |
+
def get_signal(self) -> Optional[Signal]:
|
| 1272 |
+
for fn in [self.detect_s1, self.detect_s2, self.detect_s3]:
|
| 1273 |
+
sig = fn()
|
| 1274 |
+
if sig is not None:
|
| 1275 |
+
return sig
|
| 1276 |
+
for fn in [self.detect_b3, self.detect_b2, self.detect_b1]:
|
| 1277 |
+
sig = fn()
|
| 1278 |
+
if sig is not None:
|
| 1279 |
+
return sig
|
| 1280 |
+
return None
|
| 1281 |
+
|
| 1282 |
+
def get_all_signals(self) -> list:
|
| 1283 |
+
all_sigs = []
|
| 1284 |
+
for fn in [self.detect_b1, self.detect_b2, self.detect_b3,
|
| 1285 |
+
self.detect_s1, self.detect_s2, self.detect_s3]:
|
| 1286 |
+
sig = fn()
|
| 1287 |
+
if sig is not None:
|
| 1288 |
+
all_sigs.append(sig)
|
| 1289 |
+
return all_sigs
|
| 1290 |
+
|
| 1291 |
+
def l36_segment_note(self) -> str:
|
| 1292 |
+
"""L36 结合律: 走势分解的唯一性靠结合律保证 —— a+A+b+B+c 的划分中, 同一段
|
| 1293 |
+
K线不能既归前段又归后段。本引擎线段划分采用特征序列分型(标准缠论)处理
|
| 1294 |
+
包含关系, 分型一旦确认即锁定段的归属, 等价于结合律的程序化执行。
|
| 1295 |
+
该函数对当前末端给出"是否存在划分歧义"的提示。"""
|
| 1296 |
+
if len(self.segs) < 2:
|
| 1297 |
+
return '第36课: 线段不足2段, 无划分歧义问题'
|
| 1298 |
+
last = self.segs[-1]
|
| 1299 |
+
n_last = len(getattr(last, 'bis', []) or [])
|
| 1300 |
+
if n_last < 3:
|
| 1301 |
+
return (f'第36课: 末段仅{n_last}笔(<3), 末端划分尚未唯一确认 —— '
|
| 1302 |
+
f'当下操作应按两种归属做完全分类预案, 等待特征序列分型锁定')
|
| 1303 |
+
return '第36课: 末段≥3笔且特征序列分型已锁定, 当前划分唯一, 无歧义'
|
| 1304 |
+
|
| 1305 |
+
def diagnose(self) -> dict:
|
| 1306 |
+
cur_price = float(self.close.iloc[-1]) if len(self.close) else 0.0
|
| 1307 |
+
last = self.bis[-1] if self.bis else None
|
| 1308 |
+
out = {k: [] for k in ('B1', 'B2', 'B3', 'S1', 'S2', 'S3')}
|
| 1309 |
+
def add(k, ok, msg):
|
| 1310 |
+
out[k].append(('✓' if ok else '✗') + ' ' + msg)
|
| 1311 |
+
add('B1', self.n_bis >= 5, f'笔数 {self.n_bis} >= 5')
|
| 1312 |
+
add('B1', self.n_pivots >= 2, f'中枢数 {self.n_pivots} >= 2')
|
| 1313 |
+
add('B1', self.trend == 'down_trend', f'当前走势={self.trend}, B1要求下跌趋势')
|
| 1314 |
+
add('B1', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, B1要求向下')
|
| 1315 |
+
add('B1', float(self.dif.iloc[-1]) < 0 if len(self.dif) else False, f'DIF={float(self.dif.iloc[-1]):.4f}, B1要求DIF<0')
|
| 1316 |
+
abc_down = self._validate_abc('down')
|
| 1317 |
+
add('B1', abc_down is not None, 'A/B/C三段背驰结构成立')
|
| 1318 |
+
if abc_down is not None:
|
| 1319 |
+
c_ok, c_low = self._check_c_new_extreme(abc_down['c_start_idx'], 'down')
|
| 1320 |
+
add('B1', c_ok, f'C段创新低{"" if c_low is None else f"({c_low:.3f})"}')
|
| 1321 |
+
add('B2', self.n_bis >= 4, f'笔数 {self.n_bis} >= 4')
|
| 1322 |
+
add('B2', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, B2要求回踩向下')
|
| 1323 |
+
prev_downs = [b for b in self.bis[:-1] if b.direction == 'down'] if last else []
|
| 1324 |
+
add('B2', bool(prev_downs), '存在同一轮前一个下跌低点作为一买锚')
|
| 1325 |
+
if last and prev_downs:
|
| 1326 |
+
prev = prev_downs[-1]
|
| 1327 |
+
add('B2', last.low >= prev.low and last.end.price > prev.end.price,
|
| 1328 |
+
f'回踩不创新低: 本次低{last.end.price:.3f} > 一买/前低{prev.end.price:.3f}')
|
| 1329 |
+
add('B2', cur_price >= last.end.price * (1 - self.PIVOT_TOLERANCE),
|
| 1330 |
+
f'现价{cur_price:.3f}未跌破B2回踩锚{last.end.price:.3f}; 跌破则二买失效')
|
| 1331 |
+
add('B3', self.n_pivots >= 1, f'中枢数 {self.n_pivots} >= 1')
|
| 1332 |
+
add('B3', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, B3要求向上确认')
|
| 1333 |
+
if self.pivots:
|
| 1334 |
+
p = self.pivots[-1]
|
| 1335 |
+
pair = self._last_exit_pullback_segments(p, 'up', 'down')
|
| 1336 |
+
add('B3', pair is not None, '存在已确认线段级别的向上离枢 + 向下回试')
|
| 1337 |
+
if pair is not None:
|
| 1338 |
+
exit_seg, pull_seg = pair
|
| 1339 |
+
add('B3', exit_seg.low <= p.zg * (1 + self.PIVOT_TOLERANCE) and exit_seg.high > p.zg,
|
| 1340 |
+
f'离枢线段突破ZG: {exit_seg.low:.3f}~{exit_seg.high:.3f}, ZG={p.zg:.3f}')
|
| 1341 |
+
add('B3', pull_seg.low >= p.zg * (1 - self.PIVOT_TOLERANCE),
|
| 1342 |
+
f'回试低点{pull_seg.low:.3f}不破ZG={p.zg:.3f}')
|
| 1343 |
+
add('B3', cur_price > p.zg, f'现价{cur_price:.3f}站上ZG={p.zg:.3f}')
|
| 1344 |
+
add('S1', self.n_bis >= 5, f'笔数 {self.n_bis} >= 5')
|
| 1345 |
+
add('S1', self.n_pivots >= 2, f'中枢数 {self.n_pivots} >= 2')
|
| 1346 |
+
add('S1', self.trend == 'up_trend', f'当前走势={self.trend}, S1要求上涨趋势')
|
| 1347 |
+
add('S1', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, S1要求向上')
|
| 1348 |
+
abc_up = self._validate_abc('up')
|
| 1349 |
+
add('S1', abc_up is not None, 'A/B/C三段顶背驰结构成立')
|
| 1350 |
+
if abc_up is not None:
|
| 1351 |
+
c_ok, c_high = self._check_c_new_extreme(abc_up['c_start_idx'], 'up')
|
| 1352 |
+
add('S1', c_ok, f'C段创新高{"" if c_high is None else f"({c_high:.3f})"}')
|
| 1353 |
+
add('S2', self.n_bis >= 4, f'笔数 {self.n_bis} >= 4')
|
| 1354 |
+
add('S2', bool(last and last.direction == 'up'), f'最后一笔方向={last.direction if last else ""}, S2要求反弹向上')
|
| 1355 |
+
prev_ups = [b for b in self.bis[:-1] if b.direction == 'up'] if last else []
|
| 1356 |
+
add('S2', bool(prev_ups), '存在同一轮前一个上涨高点作为一卖锚')
|
| 1357 |
+
if last and prev_ups:
|
| 1358 |
+
prev = prev_ups[-1]
|
| 1359 |
+
add('S2', last.high < prev.high and last.end.price < prev.end.price,
|
| 1360 |
+
f'反弹不创新高: 本次高{last.end.price:.3f} < 一卖/前高{prev.end.price:.3f}')
|
| 1361 |
+
add('S2', cur_price <= last.end.price * (1 + self.PIVOT_TOLERANCE),
|
| 1362 |
+
f'现价{cur_price:.3f}未重新升破S2反弹锚{last.end.price:.3f}; 升破则二卖失效')
|
| 1363 |
+
add('S3', self.n_pivots >= 1, f'中枢数 {self.n_pivots} >= 1')
|
| 1364 |
+
add('S3', bool(last and last.direction == 'down'), f'最后一笔方向={last.direction if last else ""}, S3要求向下确认')
|
| 1365 |
+
if self.pivots:
|
| 1366 |
+
p = self.pivots[-1]
|
| 1367 |
+
pair = self._last_exit_pullback_segments(p, 'down', 'up')
|
| 1368 |
+
add('S3', pair is not None, '存在已确认线段级别的向下离枢 + 向上回抽')
|
| 1369 |
+
if pair is not None:
|
| 1370 |
+
exit_seg, pull_seg = pair
|
| 1371 |
+
add('S3', exit_seg.high >= p.zd * (1 - self.PIVOT_TOLERANCE) and exit_seg.low < p.zd,
|
| 1372 |
+
f'离枢线段跌破ZD: {exit_seg.low:.3f}~{exit_seg.high:.3f}, ZD={p.zd:.3f}')
|
| 1373 |
+
add('S3', pull_seg.high <= p.zd * (1 + self.PIVOT_TOLERANCE),
|
| 1374 |
+
f'回抽高点{pull_seg.high:.3f}不破ZD={p.zd:.3f}')
|
| 1375 |
+
add('S3', cur_price < p.zd, f'现价{cur_price:.3f}跌破ZD={p.zd:.3f}')
|
| 1376 |
+
return out
|
| 1377 |
+
|
| 1378 |
+
|
| 1379 |
+
class SameLevelDecomposition:
|
| 1380 |
+
def __init__(self, analyzer: 'ChanAnalyzer'):
|
| 1381 |
+
self.an = analyzer
|
| 1382 |
+
self.segs = analyzer.segs
|
| 1383 |
+
|
| 1384 |
+
def current_phase(self) -> dict:
|
| 1385 |
+
if len(self.segs) < 2:
|
| 1386 |
+
return {'seg_dir': '', 'stage': 'unknown', 'action': 'WATCH',
|
| 1387 |
+
'reason': '线段不足, 无法做同级别分解'}
|
| 1388 |
+
last = self.segs[-1]
|
| 1389 |
+
prev = self.segs[-2]
|
| 1390 |
+
seg_dir = last.direction
|
| 1391 |
+
if seg_dir == 'up':
|
| 1392 |
+
stage = 'up_run'
|
| 1393 |
+
prev_up = None
|
| 1394 |
+
for s in reversed(self.segs[:-1]):
|
| 1395 |
+
if s.direction == 'up':
|
| 1396 |
+
prev_up = s; break
|
| 1397 |
+
if prev_up is None:
|
| 1398 |
+
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'HOLD',
|
| 1399 |
+
'reason': '向上段运作中(无前向上段可比), 持有'}
|
| 1400 |
+
if last.high <= prev_up.high:
|
| 1401 |
+
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'SELL',
|
| 1402 |
+
'reason': f'向上段不创新高({last.high:.3f}≤前高{prev_up.high:.3f}) → 先卖(第38课)'}
|
| 1403 |
+
dg = self.an.assess_divergence(prev_up.start.date, prev_up.end.date,
|
| 1404 |
+
last.start.date, last.end.date, 'up')
|
| 1405 |
+
if dg.grade != 'NONE':
|
| 1406 |
+
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'SELL',
|
| 1407 |
+
'reason': f'向上段创新高但盘整背驰({dg.grade}) → 卖(第38课)'}
|
| 1408 |
+
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'HOLD',
|
| 1409 |
+
'reason': '向上段创新高且不背驰 → 持有(第38课)'}
|
| 1410 |
+
else:
|
| 1411 |
+
stage = 'down_run'
|
| 1412 |
+
prev_down = None
|
| 1413 |
+
for s in reversed(self.segs[:-1]):
|
| 1414 |
+
if s.direction == 'down':
|
| 1415 |
+
prev_down = s; break
|
| 1416 |
+
if prev_down is None:
|
| 1417 |
+
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'WATCH',
|
| 1418 |
+
'reason': '向下段运作中(无前向下段可比), 观望等买点'}
|
| 1419 |
+
if last.low >= prev_down.low:
|
| 1420 |
+
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'BUY',
|
| 1421 |
+
'reason': f'向下段不创新低({last.low:.3f}≥前低{prev_down.low:.3f}) → 买入(第38课)'}
|
| 1422 |
+
dg = self.an.assess_divergence(prev_down.start.date, prev_down.end.date,
|
| 1423 |
+
last.start.date, last.end.date, 'down')
|
| 1424 |
+
if dg.grade != 'NONE':
|
| 1425 |
+
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'BUY',
|
| 1426 |
+
'reason': f'向下段创新低但盘整背驰({dg.grade}) → 买入(第38课)'}
|
| 1427 |
+
return {'seg_dir': seg_dir, 'stage': stage, 'action': 'WATCH',
|
| 1428 |
+
'reason': '向下段创新低且不背驰 → 观望等下跌背驰(第38课)'}
|
| 1429 |
+
|
| 1430 |
+
|
| 1431 |
+
class BottomTracker:
|
| 1432 |
+
def __init__(self):
|
| 1433 |
+
self.state = 'none'
|
| 1434 |
+
|
| 1435 |
+
def update(self, analyzer: 'ChanAnalyzer') -> str:
|
| 1436 |
+
snap = analyzer.bottom_construction_state()
|
| 1437 |
+
if self.state in ('none', 'failed', 'completed'):
|
| 1438 |
+
if snap == 'constructing':
|
| 1439 |
+
self.state = 'constructing'
|
| 1440 |
+
elif snap == 'completed':
|
| 1441 |
+
self.state = 'completed'
|
| 1442 |
+
elif snap == 'failed':
|
| 1443 |
+
self.state = 'failed'
|
| 1444 |
+
else:
|
| 1445 |
+
self.state = 'none'
|
| 1446 |
+
elif self.state == 'constructing':
|
| 1447 |
+
if snap == 'completed':
|
| 1448 |
+
self.state = 'completed'
|
| 1449 |
+
elif snap == 'failed':
|
| 1450 |
+
self.state = 'failed'
|
| 1451 |
+
return self.state
|
chan_multilevel.py
CHANGED
|
@@ -1,884 +1,123 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
【本版改动 · 卖点区间套修复 (第24/44课)】
|
| 4 |
-
|
| 5 |
-
旧逻辑的两个病根(对应回测中"卖在低位"与"提前卖飞"):
|
| 6 |
-
① 卖在低位: 严格模式要求"30分钟出现三类卖点S3"才印证日线卖点。但S3是
|
| 7 |
-
次级别已经跌破其中枢、反抽不回的【转折确认点】—— 它出现时价格早已
|
| 8 |
-
离开顶部一大截。拿S3当卖出闸门, 等于规定"必须先跌下来才准卖", 结构上
|
| 9 |
-
注定卖不到一卖的高位, 最后落在二卖/三卖的低位。
|
| 10 |
-
② 提前卖飞: 宽松模式下"30m最后一笔向下"即印证。第24课明示: 小级别背驰
|
| 11 |
-
(更别说仅仅一笔向下)未必引发大级别转折。任何一次30m级别的正常回调都
|
| 12 |
-
可能把日线WEAK级别的卖点"确认"掉 → 还没到顶就出局。
|
| 13 |
-
而且旧代码对次级别卖点【不做时间嵌套检查】: 30m在日线C段开始之前的
|
| 14 |
-
旧背驰也会被当作印证 —— "区间套"三个字里的"区间"丢了。
|
| 15 |
-
|
| 16 |
-
新逻辑 (CFG['sell_nested_interval']=True):
|
| 17 |
-
第44课区间套的本义: 大级别进入背驰段后, 在该背驰段【时间区间内】找次级别
|
| 18 |
-
的背驰段, 再在其中找次次级别的背驰段, 逐级收敛到精确转折点。
|
| 19 |
-
对日线S1/S2, 次级别印证按优先级:
|
| 20 |
-
① 嵌套顶背驰: 30m出现S1, 且其C段顶部落在日线背驰段(最后中枢之后→当下)
|
| 21 |
-
的时间窗口内、顶部价位贴近日线C段高点 → 精确卖点, 卖在高位区。
|
| 22 |
-
② 次级别转折确认: 30m出现S3, 或30m末笔已跌破其最近中枢下沿ZD(第92课
|
| 23 |
-
向下变盘) → 转折已确认, 偏晚但必须卖。
|
| 24 |
-
③ 两者皆无 → 次级别动能未竭, 第24课: 顶未到 → 【不卖】(防卖飞),
|
| 25 |
-
返回 action=HOLD + sell_armed=True 进入"区间套布防"状态:
|
| 26 |
-
回测端持仓转入布防, 之后任一条件触发(嵌套背驰出现/破30m中枢ZD/
|
| 27 |
-
较布防峰值回落超阈值)即离场 —— 既不提前卖飞, 也不一路坐滑梯到三卖。
|
| 28 |
-
S3(日线三卖)不走嵌套背驰: 三卖本身就是转折确认型卖点, 维持原确认逻辑。
|
| 29 |
"""
|
| 30 |
from __future__ import annotations
|
| 31 |
-
from dataclasses import dataclass, field
|
| 32 |
-
from typing import Optional
|
| 33 |
-
import numpy as np
|
| 34 |
-
import pandas as pd
|
| 35 |
-
|
| 36 |
-
# from chan_engine import ChanAnalyzer, Signal
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
# ── 卖点区间套参数 (第24/44课) ──
|
| 40 |
-
NESTED_TOP_PRICE_TOL = 0.03 # 嵌套顶背驰的顶部须贴近父级C段高点(3%以内), 否则属上一波动能
|
| 41 |
-
NESTED_WINDOW_PAD_DAYS = 3 # 时间嵌套窗口的左侧容差(日)
|
| 42 |
-
SELL_ARM_PEAK_DROP = 0.04 # 布防后较峰值价回落≥4% → 顶部确认离场(回测端使用)
|
| 43 |
-
SELL_ARM_DISARM_BREAK = 0.03 # 布防后强势创新高超3%且卖点消失 → 背驰被消化, 撤防(第26课)
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def _default_make_analyzer(level, df):
|
| 47 |
-
return ChanAnalyzer(df.reset_index(drop=True))
|
| 48 |
-
|
| 49 |
-
_MAKE_ANALYZER = _default_make_analyzer
|
| 50 |
-
|
| 51 |
-
def set_analyzer_factory(fn):
|
| 52 |
-
global _MAKE_ANALYZER
|
| 53 |
-
_MAKE_ANALYZER = fn or _default_make_analyzer
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def resample_weekly(df_daily: pd.DataFrame) -> pd.DataFrame:
|
| 57 |
-
if df_daily.empty:
|
| 58 |
-
return df_daily.copy()
|
| 59 |
-
d = df_daily.copy()
|
| 60 |
-
d['date'] = pd.to_datetime(d['date'])
|
| 61 |
-
d = d.sort_values('date').reset_index(drop=True)
|
| 62 |
-
wk_period = d['date'].dt.to_period('W-FRI')
|
| 63 |
-
agg = {'open': 'first', 'high': 'max', 'low': 'min', 'close': 'last'}
|
| 64 |
-
if 'volume' in d.columns: agg['volume'] = 'sum'
|
| 65 |
-
if 'amount' in d.columns: agg['amount'] = 'sum'
|
| 66 |
-
g = d.groupby(wk_period, sort=True)
|
| 67 |
-
wk = g.agg(agg)
|
| 68 |
-
last_real = g['date'].max()
|
| 69 |
-
wk = wk.dropna(subset=['open', 'high', 'low', 'close']).reset_index(drop=True)
|
| 70 |
-
wk['date'] = list(last_real.values)
|
| 71 |
-
return wk
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
def resample_monthly(df_daily: pd.DataFrame) -> pd.DataFrame:
|
| 75 |
-
if df_daily.empty:
|
| 76 |
-
return df_daily.copy()
|
| 77 |
-
d = df_daily.copy()
|
| 78 |
-
d['date'] = pd.to_datetime(d['date'])
|
| 79 |
-
d = d.sort_values('date').reset_index(drop=True)
|
| 80 |
-
mp = d['date'].dt.to_period('M')
|
| 81 |
-
agg = {'open': 'first', 'high': 'max', 'low': 'min', 'close': 'last'}
|
| 82 |
-
if 'volume' in d.columns: agg['volume'] = 'sum'
|
| 83 |
-
if 'amount' in d.columns: agg['amount'] = 'sum'
|
| 84 |
-
g = d.groupby(mp, sort=True)
|
| 85 |
-
mo = g.agg(agg)
|
| 86 |
-
last_real = g['date'].max()
|
| 87 |
-
mo = mo.dropna(subset=['open', 'high', 'low', 'close']).reset_index(drop=True)
|
| 88 |
-
mo['date'] = list(last_real.values)
|
| 89 |
-
return mo
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
@dataclass
|
| 93 |
-
class LevelView:
|
| 94 |
-
level: str
|
| 95 |
-
trend: str
|
| 96 |
-
n_pivots: int
|
| 97 |
-
n_bis: int
|
| 98 |
-
last_bi_dir: str
|
| 99 |
-
signal: Optional[Signal]
|
| 100 |
-
zg: Optional[float]
|
| 101 |
-
zd: Optional[float]
|
| 102 |
-
dif: Optional[float]
|
| 103 |
-
last_date: Optional[pd.Timestamp]
|
| 104 |
-
diagnostics: dict = field(default_factory=dict)
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
@dataclass
|
| 108 |
-
class MultiLevelSignal:
|
| 109 |
-
code: str
|
| 110 |
-
analysis_date: pd.Timestamp
|
| 111 |
-
weekly: LevelView
|
| 112 |
-
daily: LevelView
|
| 113 |
-
m30: Optional[LevelView]
|
| 114 |
-
action: str
|
| 115 |
-
confidence: str
|
| 116 |
-
final_kind: str
|
| 117 |
-
cur_price: float
|
| 118 |
-
chain: list = field(default_factory=list)
|
| 119 |
-
blocked_reason: str = ''
|
| 120 |
-
note: str = ''
|
| 121 |
-
confidence_reasons: list = field(default_factory=list)
|
| 122 |
-
diagnostics: dict = field(default_factory=dict)
|
| 123 |
-
monthly: Optional[LevelView] = None
|
| 124 |
-
# ── 卖点区间套布防 (第44课) ──
|
| 125 |
-
sell_armed: bool = False # 日线S1/S2背驰已现、次级别动能未竭 → 持仓布防
|
| 126 |
-
arm_zd: Optional[float] = None # 布防线①: 30m最近中枢下沿ZD(跌破即次级别转折确认)
|
| 127 |
-
arm_high: Optional[float] = None # 父级背驰段C段高点(用于"新高消化背驰"撤防判定)
|
| 128 |
-
|
| 129 |
-
def explain(self) -> str:
|
| 130 |
-
lines = [f' [{self.code}] {self.analysis_date.strftime("%Y-%m-%d")} ¥{self.cur_price:.3f}']
|
| 131 |
-
lines.append(f' 最终: {self.action} 置信度={self.confidence}'
|
| 132 |
-
+ (f' 信号={self.final_kind}' if self.final_kind else ''))
|
| 133 |
-
lines.append(' ── 逐级裁决链 ──')
|
| 134 |
-
for lvl, concl, src in self.chain:
|
| 135 |
-
lines.append(f' [{lvl:<7s}] {concl}')
|
| 136 |
-
if src:
|
| 137 |
-
lines.append(f' └ 依据: {src}')
|
| 138 |
-
if self.blocked_reason:
|
| 139 |
-
lines.append(f' ⚠ 拦截: {self.blocked_reason}')
|
| 140 |
-
if self.note:
|
| 141 |
-
lines.append(f' 说明: {self.note}')
|
| 142 |
-
if self.confidence_reasons:
|
| 143 |
-
lines.append(' 置信度降档明细:')
|
| 144 |
-
for i, r in enumerate(self.confidence_reasons, 1):
|
| 145 |
-
lines.append(f' {i}. {r}')
|
| 146 |
-
if self.diagnostics:
|
| 147 |
-
lines.append(' 日线买卖点逐项诊断:')
|
| 148 |
-
for k in ('B1', 'B2', 'B3', 'S1', 'S2', 'S3'):
|
| 149 |
-
rows = self.diagnostics.get(k) or []
|
| 150 |
-
if not rows:
|
| 151 |
-
continue
|
| 152 |
-
ok = all(str(x).startswith('✓') for x in rows)
|
| 153 |
-
lines.append(f' {k} {"满足" if ok else "不满足"}')
|
| 154 |
-
for row in rows:
|
| 155 |
-
lines.append(f' {row}')
|
| 156 |
-
else:
|
| 157 |
-
lines.append(' 日线买卖点逐项诊断: 未生成')
|
| 158 |
-
return '\n'.join(lines)
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
class MultiLevelChan:
|
| 162 |
-
CFG = {
|
| 163 |
-
'use_monthly_gate': True,
|
| 164 |
-
'monthly_ma_filter': False,
|
| 165 |
-
'require_sublevel_sell_confirm': False,
|
| 166 |
-
'mode': 'short',
|
| 167 |
-
'zhongyin_block_buy': False,
|
| 168 |
-
'expose_m30_nosignal': False,
|
| 169 |
-
'require_60m_buy_confirm': False, # L44区间套: 60m为日线直接次级别, 买点须经其印证
|
| 170 |
-
# L50 MACD级别选择: 看MACD要选"黄白线和柱子清晰"的级别。30m判据模糊而60m
|
| 171 |
-
# 清晰且60m已印证时, 采信60m的印证结论(避免被模糊级别的噪声误杀好买点)。
|
| 172 |
-
'l50_macd_level_select': False,
|
| 173 |
-
# ── 卖点区间套(第24/44课): 嵌套顶背驰精确定位 + 布防防卖飞 ──
|
| 174 |
-
# True = S1/S2用"时间嵌套的次级别顶背驰"定位高点; 未嵌套确认时不卖而布防
|
| 175 |
-
# False = 维持旧逻辑(30m要S3 / 宽松末笔向下即卖)
|
| 176 |
-
'sell_nested_interval': True,
|
| 177 |
-
# L24: 周线上涨+日线盘整背驰的S1/S2 → 不全清, 转布防短差(撤防可骑回趋势)
|
| 178 |
-
'l24_weekly_uptrend_arm': False, # 实测净亏(好卖点也被转布防), 默认关
|
| 179 |
-
}
|
| 180 |
-
|
| 181 |
-
# ── 速度优化: 次级别K线只取最近N根做缠论分解 ──
|
| 182 |
-
# 第17/44课: 次级别(60m/30m/15m/5m/1m)的职责是"印证当下转折", 其买卖点
|
| 183 |
-
# 只取决于最近的笔/线段/中枢结构; 几年前的分钟级历史对当下印证毫无贡献,
|
| 184 |
-
# 却让每个回测日都对上万根分钟K线重做合并/分型/笔/段, 是最大的耗时点。
|
| 185 |
-
# 截断只作用于次级别印证, 日/周/月线仍用全history(年线、月线大方向不受影响)。
|
| 186 |
-
SUB_TAIL = {'15m': 4000, '5m': 4800, '1m': 2000}
|
| 187 |
-
|
| 188 |
-
def __init__(self, df_daily, df_weekly=None, df_monthly=None, df_30m=None, df_60m=None,
|
| 189 |
-
df_15m=None, df_5m=None, df_1m=None, code='', strict=True):
|
| 190 |
-
self.code = code
|
| 191 |
-
self.strict = strict
|
| 192 |
-
self.df_daily = df_daily.reset_index(drop=True) if not df_daily.empty else df_daily
|
| 193 |
-
self.df_30m = df_30m.reset_index(drop=True) if df_30m is not None and not df_30m.empty else None
|
| 194 |
-
self.df_60m = df_60m.reset_index(drop=True) if df_60m is not None and not df_60m.empty else None
|
| 195 |
-
self.df_15m = df_15m.reset_index(drop=True) if df_15m is not None and not df_15m.empty else None
|
| 196 |
-
self.df_5m = df_5m.reset_index(drop=True) if df_5m is not None and not df_5m.empty else None
|
| 197 |
-
self.df_1m = df_1m.reset_index(drop=True) if df_1m is not None and not df_1m.empty else None
|
| 198 |
-
self.df_weekly = df_weekly.reset_index(drop=True) if df_weekly is not None and not df_weekly.empty else None
|
| 199 |
-
self.df_monthly = df_monthly.reset_index(drop=True) if df_monthly is not None and not df_monthly.empty else None
|
| 200 |
-
|
| 201 |
-
@staticmethod
|
| 202 |
-
def _make_view(level, df, an=None, diagnose=True):
|
| 203 |
-
if an is None:
|
| 204 |
-
if df is None or len(df) < 30:
|
| 205 |
-
return None
|
| 206 |
-
try:
|
| 207 |
-
an = _MAKE_ANALYZER(level, df)
|
| 208 |
-
except Exception:
|
| 209 |
-
return None
|
| 210 |
-
if an is None:
|
| 211 |
-
return None
|
| 212 |
-
sig = an.get_signal()
|
| 213 |
-
zg = an.pivots[-1].zg if an.n_pivots > 0 else None
|
| 214 |
-
zd = an.pivots[-1].zd if an.n_pivots > 0 else None
|
| 215 |
-
return LevelView(level=level, trend=an.trend, n_pivots=an.n_pivots, n_bis=an.n_bis,
|
| 216 |
-
last_bi_dir=an.bis[-1].direction if an.n_bis else '', signal=sig,
|
| 217 |
-
zg=zg, zd=zd, dif=float(an.dif.iloc[-1]) if len(an.dif) else None,
|
| 218 |
-
last_date=pd.Timestamp(df['date'].iloc[-1]),
|
| 219 |
-
diagnostics=(an.diagnose() if diagnose else {}))
|
| 220 |
-
|
| 221 |
-
# ──────────────────────────────────────────────────────────────────
|
| 222 |
-
# 卖点区间套核心: 时间嵌套的次级别顶背驰 (第44课)
|
| 223 |
-
# ──────────────────────────────────────────────────────────────────
|
| 224 |
-
@staticmethod
|
| 225 |
-
def _parent_sell_window(parent_sig):
|
| 226 |
-
"""父级(日线)背驰段的时间窗口与顶部价。
|
| 227 |
-
S1: 背驰段C段 = 最后中枢结束 → 当下; 顶 = C段高点 c_high。
|
| 228 |
-
S2: 窗口 = 一卖出现 → 当下(反抽段); 顶 = 一卖高点 prev_high。"""
|
| 229 |
-
ex = (parent_sig.extras or {}) if parent_sig is not None else {}
|
| 230 |
-
if parent_sig is None:
|
| 231 |
-
return '', '', None
|
| 232 |
-
if parent_sig.kind == 'S1':
|
| 233 |
-
w_start = ex.get('pivot_end_date') or ex.get('a_high_date') or ''
|
| 234 |
-
top = ex.get('c_high')
|
| 235 |
-
else: # S2
|
| 236 |
-
w_start = ex.get('s1_date') or ex.get('prev_high_date') or ''
|
| 237 |
-
top = ex.get('prev_high')
|
| 238 |
-
w_end = ex.get('price_date') or ''
|
| 239 |
-
return w_start, w_end, top
|
| 240 |
-
|
| 241 |
-
def _nested_sell_check(self, sub_an, parent_sig, lvl_name, parent_kind):
|
| 242 |
-
"""第44课区间套(卖点版): 在父级背驰段时间窗口内找次级别的嵌套顶背驰。
|
| 243 |
-
优先级: ①嵌套S1(精确高点, 卖在高位)
|
| 244 |
-
②S3 / 末笔破次级别中枢ZD(转折已确认, 偏晚但必卖)
|
| 245 |
-
③都没有 → 第24课: 次级别动能未竭, 顶未到 → 不卖(防卖飞), 转布防。
|
| 246 |
-
返回 (confirmed: bool, note: str)。"""
|
| 247 |
-
w_start, w_end, parent_top = self._parent_sell_window(parent_sig)
|
| 248 |
-
|
| 249 |
-
def in_window(dstr):
|
| 250 |
-
if not w_start or not dstr:
|
| 251 |
-
return True # 信息不足时不因窗口否决(保守放行, 由价位贴近度把关)
|
| 252 |
-
try:
|
| 253 |
-
d = pd.Timestamp(dstr)
|
| 254 |
-
lo = pd.Timestamp(w_start) - pd.Timedelta(days=NESTED_WINDOW_PAD_DAYS)
|
| 255 |
-
hi = (pd.Timestamp(w_end) if w_end else d) + pd.Timedelta(days=1)
|
| 256 |
-
return lo <= d <= hi
|
| 257 |
-
except Exception:
|
| 258 |
-
return True
|
| 259 |
-
|
| 260 |
-
rejected = ''
|
| 261 |
-
# ① 嵌套顶背驰 S1 —— 区间套的本体: 背驰段中套背驰段
|
| 262 |
-
s1 = sub_an.detect_s1()
|
| 263 |
-
if s1 is not None:
|
| 264 |
-
ex2 = s1.extras or {}
|
| 265 |
-
td = ex2.get('c_high_date', '')
|
| 266 |
-
tp = ex2.get('c_high')
|
| 267 |
-
near_top = (parent_top is None or tp is None
|
| 268 |
-
or tp >= parent_top * (1 - NESTED_TOP_PRICE_TOL))
|
| 269 |
-
if in_window(td) and near_top:
|
| 270 |
-
ptxt = f'(顶¥{tp:.3f}@{td})' if tp else ''
|
| 271 |
-
return True, (f'{lvl_name}嵌套顶背驰S1{ptxt}落在日线{parent_kind}背驰段窗口内 '
|
| 272 |
-
f'—— 第44课区间套: 背驰段中套背驰段, 精确定位顶部, 卖在高位区')
|
| 273 |
-
rejected = (f'{lvl_name}虽有S1但【不嵌套】(顶@{td or "?"}不在父级背驰段'
|
| 274 |
-
f'[{w_start}~{w_end}]窗口内, 或低于父级顶{NESTED_TOP_PRICE_TOL:.0%}以上)'
|
| 275 |
-
f' → 属上一波的动能衰竭, 不作本次印证(防卖飞); ')
|
| 276 |
-
# ①b 父级为S2时, 次级别S2(一卖后反抽确认)也可嵌套印证
|
| 277 |
-
if parent_kind == 'S2':
|
| 278 |
-
s2 = sub_an.detect_s2()
|
| 279 |
-
if s2 is not None:
|
| 280 |
-
ex2 = s2.extras or {}
|
| 281 |
-
td = ex2.get('cur_high_date', '')
|
| 282 |
-
if in_window(td):
|
| 283 |
-
return True, (f'{lvl_name}嵌套二卖S2(反抽顶@{td})落在日线S2窗口内 '
|
| 284 |
-
f'—— 第14课: 大级别二卖由次级别相应卖点定位')
|
| 285 |
-
# ② 转折已确认型: S3 / 末笔已破次级别中枢下沿
|
| 286 |
-
s3 = sub_an.detect_s3()
|
| 287 |
-
if s3 is not None:
|
| 288 |
-
return True, (f'{lvl_name}出现三类卖点S3 —— 第44课: 最后一个次级别中枢出现三卖, '
|
| 289 |
-
f'转折已确认(偏晚, 但必须卖)')
|
| 290 |
-
try:
|
| 291 |
-
osc = sub_an.zhongshu_oscillation_monitor()
|
| 292 |
-
if osc.get('alert') and osc.get('direction') == 'down':
|
| 293 |
-
return True, (f'{lvl_name}末笔已离开其最近中枢下沿(第92课向下变盘) '
|
| 294 |
-
f'—— 次级别转折确认, 高位区兑现')
|
| 295 |
-
except Exception:
|
| 296 |
-
pass
|
| 297 |
-
# ③ 次级别上冲动能已竭的当下确认: 末笔向下 + MACD柱已翻负(黄白线收敛下行)。
|
| 298 |
-
# 第24/44课: 区间套要的是"次级别走势的当下转折"; 末笔转下且红柱消失,
|
| 299 |
-
# 说明次级别这一冲已经结束 —— 此时日线背驰卖点立即执行, 不再等更深的破位
|
| 300 |
-
# (这正是旧版"等30m三卖"卖在低位的病根)。只有次级别仍在上冲(末笔向上/
|
| 301 |
-
# 红柱未消)时才转入布防, 防的才是真正的"卖飞"。
|
| 302 |
-
try:
|
| 303 |
-
last_bi = sub_an.bis[-1] if sub_an.n_bis else None
|
| 304 |
-
bar_now = float(sub_an.macd_bar.iloc[-1]) if len(sub_an.macd_bar) else 0.0
|
| 305 |
-
if last_bi is not None and last_bi.direction == 'down' and bar_now < 0:
|
| 306 |
-
return True, (f'{lvl_name}末笔向下且MACD柱已翻负 —— 第24课: 次级别上冲动能已竭, '
|
| 307 |
-
f'当下转折确认, 日线背驰卖点立即执行(不等{lvl_name}跌出三卖)')
|
| 308 |
-
except Exception:
|
| 309 |
-
pass
|
| 310 |
-
return False, (rejected + f'{lvl_name}动能未竭(末笔仍向上/MACD红柱未消): 无嵌套顶背驰/无S3 '
|
| 311 |
-
f'→ 第24课: 次级别未转折, 大级别顶未到 → 暂不卖(防卖飞), 转入区间套布防')
|
| 312 |
-
|
| 313 |
-
def _confirm_30m_for_buy(self, m30, parent_kind=''):
|
| 314 |
-
if m30 is None or m30.n_bis < 5:
|
| 315 |
-
return False, '30分钟笔数不足(<5), 无法做次级别精确印证'
|
| 316 |
-
if parent_kind == 'B2':
|
| 317 |
-
if m30.detect_b1() is not None:
|
| 318 |
-
return True, '30分钟出现一类买点B1 —— 第14课: 大级别第二类买点由次一级别相应走势的一类买点构成'
|
| 319 |
-
if m30.detect_b2() is not None:
|
| 320 |
-
return True, '30分钟出现二类买点B2 —— 次级别一买后的回试确认, 属延后确认, 精度低于B1'
|
| 321 |
-
return False, '30分钟未出现B1/B2 —— 大级别二买缺少次级别买点确认'
|
| 322 |
-
for fn, name in ((m30.detect_b1,'B1'),(m30.detect_b3,'B3'),(m30.detect_b2,'B2')):
|
| 323 |
-
if fn() is not None:
|
| 324 |
-
return True, f'30分钟出现标准买点{name} —— 第44课: 次级别走势确认转折, 日线买点成立'
|
| 325 |
-
if self.strict:
|
| 326 |
-
return False, '30分钟未出现任何标准买点(B1/B2/B3) —— 严格模式: 第44课次级别印证未满足, 日线买点暂不成立'
|
| 327 |
-
if m30.bis[-1].direction == 'up':
|
| 328 |
-
return True, '30分钟最后一笔向上, 次级别已启动(宽松确认)'
|
| 329 |
-
return False, '30分钟最后一笔仍向下且无买点, 次级别未确认转折'
|
| 330 |
-
|
| 331 |
-
def _confirm_30m_for_sell(self, m30, parent_kind='', parent_sig=None):
|
| 332 |
-
if m30 is None or m30.n_bis < 5:
|
| 333 |
-
return False, '30分钟笔数不足(<5), 无法做次级别精确印证'
|
| 334 |
-
# ── 新: 卖点区间套(第24/44课) —— S1/S2用嵌套顶背驰定位高点 ──
|
| 335 |
-
if (self.CFG.get('sell_nested_interval') and parent_sig is not None
|
| 336 |
-
and parent_kind in ('S1', 'S2')):
|
| 337 |
-
return self._nested_sell_check(m30, parent_sig, '30分钟', parent_kind)
|
| 338 |
-
# ── 旧逻辑(S3父级 / 开关关闭时) ──
|
| 339 |
-
if parent_kind == 'S2':
|
| 340 |
-
if m30.detect_s1() is not None:
|
| 341 |
-
return True, '30分钟出现一类卖点S1 —— 大级别第二类卖点由次一级别相应走势的一类卖点精确定位'
|
| 342 |
-
if m30.detect_s2() is not None:
|
| 343 |
-
return True, '30分钟出现二类卖点S2 —— 次级别一卖后的反抽确认, 属延后确认, 精度低于S1'
|
| 344 |
-
return False, '30分钟未出现S1/S2 —— 大级别二卖缺少次级别卖点确认'
|
| 345 |
-
if m30.detect_s3() is not None:
|
| 346 |
-
return True, '30分钟出现三类卖点S3 —— 严格满足第44课"小背驰-大转折定理"的必要条件(最后一个次级别中枢出现三卖)'
|
| 347 |
-
if self.strict:
|
| 348 |
-
return False, '30分钟未出现三类卖点S3 —— 严格模式: 第44课明确要求"最后一个次级别中枢出现三卖", 必要条件未满足, 日线卖点不构成大级别转折'
|
| 349 |
-
if m30.detect_s1() is not None:
|
| 350 |
-
return True, '30分钟出现一类卖点(顶背驰), 次级别转折确认(宽松)'
|
| 351 |
-
if m30.bis[-1].direction == 'down':
|
| 352 |
-
return True, '30分钟最后一笔向下, 次级别已转弱(宽松确认)'
|
| 353 |
-
return False, '30分钟未出现卖点且最后一笔仍向上, 次级别未确认转折'
|
| 354 |
-
|
| 355 |
-
def _confirm_finer(self, an, is_buy, lvl_name, parent_name, parent_kind='', parent_sig=None):
|
| 356 |
-
if an is None or an.n_bis < 5:
|
| 357 |
-
return False, f'{lvl_name}笔数不足(<5), 无法做次级别精确印证'
|
| 358 |
-
if is_buy:
|
| 359 |
-
if parent_kind == 'B2':
|
| 360 |
-
if an.detect_b1() is not None:
|
| 361 |
-
return True, f'{lvl_name}出现B1 —— 第14课: {parent_name}二买由次一级别一买构成'
|
| 362 |
-
if an.detect_b2() is not None:
|
| 363 |
-
return True, f'{lvl_name}出现B2 —— 次级别一买后的回试确认, 属延后确认, 精度低于B1'
|
| 364 |
-
return False, f'{lvl_name}未出现B1/B2 —— {parent_name}二买缺少次级别买点确认'
|
| 365 |
-
for fn, name in ((an.detect_b1,'B1'),(an.detect_b3,'B3'),(an.detect_b2,'B2')):
|
| 366 |
-
if fn() is not None:
|
| 367 |
-
return True, f'{lvl_name}出现标准买点{name} —— 第44课逐级处理: {lvl_name}走势确认{parent_name}的转折'
|
| 368 |
-
if self.strict:
|
| 369 |
-
return False, f'{lvl_name}未出现标准买点(B1/B2/B3) —— 严格模式: 末级印证未满足, 信号降级'
|
| 370 |
-
if an.bis[-1].direction == 'up':
|
| 371 |
-
return True, f'{lvl_name}最后一笔向上, 次级别已启动(宽松确认)'
|
| 372 |
-
return False, f'{lvl_name}最后一笔仍向下且无买点, 末级未确认, 信号降级'
|
| 373 |
-
else:
|
| 374 |
-
# ── 新: 卖点区间套向更细级别逐级传递(同一父级背驰段窗口) ──
|
| 375 |
-
if (self.CFG.get('sell_nested_interval') and parent_sig is not None
|
| 376 |
-
and parent_kind in ('S1', 'S2')):
|
| 377 |
-
ok, note = self._nested_sell_check(an, parent_sig, lvl_name, parent_kind)
|
| 378 |
-
if ok:
|
| 379 |
-
return True, note + f' —— 第44课逐级处理: {lvl_name}确认{parent_name}的转折'
|
| 380 |
-
return False, note
|
| 381 |
-
if parent_kind == 'S2':
|
| 382 |
-
if an.detect_s1() is not None:
|
| 383 |
-
return True, f'{lvl_name}出现S1 —— {parent_name}二卖由次一级别一卖精确定位'
|
| 384 |
-
if an.detect_s2() is not None:
|
| 385 |
-
return True, f'{lvl_name}出现S2 —— 次级别一卖后的反抽确认, 属延后确认, 精度低于S1'
|
| 386 |
-
return False, f'{lvl_name}未出现S1/S2 —— {parent_name}二卖缺少次级别卖点确认'
|
| 387 |
-
if an.detect_s3() is not None:
|
| 388 |
-
return True, f'{lvl_name}出现三类卖点S3 —— 第44课逐级处理: {lvl_name}走势确认{parent_name}的转折'
|
| 389 |
-
if self.strict:
|
| 390 |
-
return False, f'{lvl_name}未出现三类卖点S3 —— 严格模式: 末级印证未满足, 信号降级'
|
| 391 |
-
if an.detect_s1() is not None:
|
| 392 |
-
return True, f'{lvl_name}出现一类卖点(顶背驰), 次级别转折确认(宽松)'
|
| 393 |
-
if an.bis[-1].direction == 'down':
|
| 394 |
-
return True, f'{lvl_name}最后一笔向下, 次级别已转弱(宽松确认)'
|
| 395 |
-
return False, f'{lvl_name}未出现卖点且最后一笔仍向上, 末级未确认, 信号降级'
|
| 396 |
-
|
| 397 |
-
def _confirm_5m(self, m5, is_buy, parent_kind='', parent_sig=None):
|
| 398 |
-
return self._confirm_finer(m5, is_buy, '5分钟', '30分钟', parent_kind, parent_sig)
|
| 399 |
-
|
| 400 |
-
def _confirm_1m(self, m1, is_buy, parent_kind='', parent_sig=None):
|
| 401 |
-
return self._confirm_finer(m1, is_buy, '1分钟', '5分钟', parent_kind, parent_sig)
|
| 402 |
-
|
| 403 |
-
def analyze(self, analysis_date=None, positions=None):
|
| 404 |
-
if self.df_daily.empty or len(self.df_daily) < 30:
|
| 405 |
-
return None
|
| 406 |
-
df_d = self.df_daily
|
| 407 |
-
if analysis_date is not None:
|
| 408 |
-
analysis_date = pd.Timestamp(analysis_date)
|
| 409 |
-
df_d = df_d[df_d['date'] <= analysis_date].reset_index(drop=True)
|
| 410 |
-
if len(df_d) < 30:
|
| 411 |
-
return None
|
| 412 |
-
last_date = pd.Timestamp(df_d['date'].iloc[-1])
|
| 413 |
-
cur_price = float(df_d['close'].iloc[-1])
|
| 414 |
-
|
| 415 |
-
if self.df_weekly is not None:
|
| 416 |
-
df_w = self.df_weekly[self.df_weekly['date'] <= last_date].reset_index(drop=True)
|
| 417 |
-
else:
|
| 418 |
-
df_w = resample_weekly(df_d)
|
| 419 |
-
if self.df_monthly is not None:
|
| 420 |
-
df_mo = self.df_monthly[self.df_monthly['date'] <= last_date].reset_index(drop=True)
|
| 421 |
-
else:
|
| 422 |
-
df_mo = resample_monthly(df_d)
|
| 423 |
-
_tail = self.SUB_TAIL or {}
|
| 424 |
-
def _cut_sub(df, lvl):
|
| 425 |
-
if df is None:
|
| 426 |
-
return None
|
| 427 |
-
sub = df[df['date'] <= last_date + pd.Timedelta(days=1)]
|
| 428 |
-
n = _tail.get(lvl, 0)
|
| 429 |
-
if n and len(sub) > n:
|
| 430 |
-
sub = sub.tail(n)
|
| 431 |
-
return sub.reset_index(drop=True)
|
| 432 |
-
df_6 = _cut_sub(self.df_60m, '60m')
|
| 433 |
-
df_m = _cut_sub(self.df_30m, '30m')
|
| 434 |
-
df_15 = _cut_sub(self.df_15m, '15m')
|
| 435 |
-
df_5 = _cut_sub(self.df_5m, '5m')
|
| 436 |
-
df_1 = _cut_sub(self.df_1m, '1m')
|
| 437 |
-
|
| 438 |
-
wv = self._make_view('weekly', df_w, diagnose=False)
|
| 439 |
-
mov = self._make_view('monthly', df_mo, diagnose=False) if self.CFG.get('use_monthly_gate') else None
|
| 440 |
-
daily_an = None
|
| 441 |
-
try:
|
| 442 |
-
if len(df_d) >= 30:
|
| 443 |
-
daily_an = _MAKE_ANALYZER('daily', df_d)
|
| 444 |
-
except Exception:
|
| 445 |
-
daily_an = None
|
| 446 |
-
dv = self._make_view('daily', df_d, an=daily_an, diagnose=False) if daily_an is not None \
|
| 447 |
-
else self._make_view('daily', df_d, diagnose=False)
|
| 448 |
-
|
| 449 |
-
m60_an = None
|
| 450 |
-
m60_built = False
|
| 451 |
-
def _get_m60():
|
| 452 |
-
nonlocal m60_an, m60_built
|
| 453 |
-
if not m60_built:
|
| 454 |
-
m60_built = True
|
| 455 |
-
if df_6 is not None and len(df_6) >= 30:
|
| 456 |
-
try: m60_an = _MAKE_ANALYZER('60m', df_6)
|
| 457 |
-
except Exception: m60_an = None
|
| 458 |
-
return m60_an
|
| 459 |
-
|
| 460 |
-
m30_an = None
|
| 461 |
-
m30_built = False
|
| 462 |
-
def _get_m30():
|
| 463 |
-
nonlocal m30_an, m30_built
|
| 464 |
-
if not m30_built:
|
| 465 |
-
m30_built = True
|
| 466 |
-
if df_m is not None and len(df_m) >= 30:
|
| 467 |
-
try: m30_an = _MAKE_ANALYZER('30m', df_m)
|
| 468 |
-
except Exception: m30_an = None
|
| 469 |
-
return m30_an
|
| 470 |
-
|
| 471 |
-
def _mv():
|
| 472 |
-
an = _get_m30()
|
| 473 |
-
if an is None:
|
| 474 |
-
return None
|
| 475 |
-
return self._make_view('30m', df_m, an=an, diagnose=False)
|
| 476 |
-
|
| 477 |
-
diagnostics = daily_an.diagnose() if daily_an is not None else {}
|
| 478 |
-
|
| 479 |
-
chain = []
|
| 480 |
-
yearline_dir = 'unknown'; yearline_val = None
|
| 481 |
-
if len(df_d) >= 60:
|
| 482 |
-
_n = min(250, len(df_d))
|
| 483 |
-
yearline_val = float(df_d['close'].tail(_n).mean()) if _n >= 20 else None
|
| 484 |
-
_ma = df_d['close'].rolling(min(250, len(df_d)), min_periods=20).mean()
|
| 485 |
-
if len(_ma) and not pd.isna(_ma.iloc[-1]):
|
| 486 |
-
yearline_val = float(_ma.iloc[-1])
|
| 487 |
-
if yearline_val is not None:
|
| 488 |
-
above = cur_price >= yearline_val
|
| 489 |
-
yearline_dir = 'above' if above else 'below'
|
| 490 |
-
ytxt = (f'现价¥{cur_price:.3f} {"≥" if above else "<"} 年线MA250 ¥{yearline_val:.3f} '
|
| 491 |
-
f'→ {"站上年线, 长线可做多" if above else "年线下方, 第106课不做多(只看反弹/卖点)"}')
|
| 492 |
-
chain.append(('年线', ytxt,
|
| 493 |
-
'第7/106课: 年线(MA250)是长线生命线; 站上才考虑做多, 跌破年线长线转空'))
|
| 494 |
-
else:
|
| 495 |
-
chain.append(('年线', '日线历史不足, 年线MA250 暂不可用', '第7/106课'))
|
| 496 |
-
|
| 497 |
-
monthly_dir = 'unknown'
|
| 498 |
-
if mov is not None:
|
| 499 |
-
monthly_dir = mov.trend
|
| 500 |
-
chain.append(('monthly', f'{monthly_dir} (月线定大方向: L69 月线看最实质大方向)',
|
| 501 |
-
'第69/108课: 月线分型/笔/线段确定中期大方向; 底部=第一类买点到中枢首次走出三买前'))
|
| 502 |
-
if self.CFG.get('monthly_ma_filter') and len(df_mo) >= 5:
|
| 503 |
-
ma5_m = float(df_mo['close'].tail(5).mean())
|
| 504 |
-
if cur_price < ma5_m and monthly_dir != 'down_trend':
|
| 505 |
-
monthly_dir = 'down_trend'
|
| 506 |
-
chain.append(('monthly', f'价({cur_price:.2f})在5月线({ma5_m:.2f})下 → 大方向按偏空处理',
|
| 507 |
-
'第106课: 5月线是长线的关键, 牛市第一轮调整不跌破5月线'))
|
| 508 |
-
|
| 509 |
-
if wv is None:
|
| 510 |
-
chain.append(('weekly', '周线数据不足(<30根), 方向未知', ''))
|
| 511 |
-
weekly_dir = 'unknown'
|
| 512 |
-
else:
|
| 513 |
-
weekly_dir = wv.trend
|
| 514 |
-
dir_txt = {'up_trend':'上涨趋势 → 日线只接受【买点】','down_trend':'下跌趋势 → 日线只接受【卖点】/观望',
|
| 515 |
-
'consolidation':'盘整 → 日线买卖点都看,但降级'}.get(weekly_dir, weekly_dir)
|
| 516 |
-
chain.append(('weekly', f'{weekly_dir} {dir_txt}',
|
| 517 |
-
'第43课: 大级别走势类型限定小级别的操作方向; 不允许"上涨+上涨""下跌+下跌"'))
|
| 518 |
-
|
| 519 |
-
if dv is None:
|
| 520 |
-
return None
|
| 521 |
-
daily_sig = dv.signal
|
| 522 |
-
if daily_sig is None:
|
| 523 |
-
chain.append(('daily', f'{dv.trend}, 无买卖点信号', ''))
|
| 524 |
-
else:
|
| 525 |
-
chain.append(('daily', f'出现 {daily_sig.kind}: {daily_sig.reason[:400]}', '第21课: 买卖点完备性定理'))
|
| 526 |
-
|
| 527 |
-
if daily_sig is None:
|
| 528 |
-
action, conf, note = self._no_signal_decision(wv, dv, cur_price)
|
| 529 |
-
m30_ns = _mv() if self.CFG.get('expose_m30_nosignal') else None
|
| 530 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=m30_ns,
|
| 531 |
-
action=action, confidence=conf, final_kind='', cur_price=cur_price, chain=chain, note=note,
|
| 532 |
-
diagnostics=diagnostics, monthly=mov)
|
| 533 |
-
|
| 534 |
-
is_buy = daily_sig.kind in ('B1','B2','B3')
|
| 535 |
-
is_sell = daily_sig.kind in ('S1','S2','S3')
|
| 536 |
-
|
| 537 |
-
if self.CFG.get('zhongyin_block_buy') and is_buy and daily_sig.kind != 'B3' and daily_an is not None:
|
| 538 |
-
zy = daily_an.in_zhongyin()
|
| 539 |
-
if zy.get('in_zhongyin'):
|
| 540 |
-
chain.append(('中阴', f'第88-90课: 当前处中阴(方向未定+BOLL收口) → 暂不开新仓: {zy["reason"][:120]}', '第89课: 中阴阶段方向未定, 不宜开新仓'))
|
| 541 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=None,
|
| 542 |
-
action='WATCH', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 543 |
-
chain=chain, blocked_reason='中阴方向未定', note='中阴阶段暂不开仓(L88-90)',
|
| 544 |
-
diagnostics=diagnostics, monthly=mov)
|
| 545 |
-
|
| 546 |
-
if positions:
|
| 547 |
-
fail_kinds = []
|
| 548 |
-
for bk, pos in positions.items():
|
| 549 |
-
stop = pos.get('stop_px')
|
| 550 |
-
if stop is not None and cur_price < stop:
|
| 551 |
-
fail_kinds.append(bk)
|
| 552 |
-
if fail_kinds:
|
| 553 |
-
chain.append(('证伪', f'持仓{"/".join(fail_kinds)}跌破建仓日锁定止损线 → 清仓', '第13/20课: 买点结构被破坏即证伪'))
|
| 554 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 555 |
-
action='SELL', confidence='HIGH', final_kind='STOP', cur_price=cur_price,
|
| 556 |
-
chain=chain, note='结构证伪止损, 次日清仓',
|
| 557 |
-
diagnostics=diagnostics, monthly=mov)
|
| 558 |
-
|
| 559 |
-
blocked = ''
|
| 560 |
-
downgrade = False
|
| 561 |
-
if weekly_dir == 'up_trend' and is_sell:
|
| 562 |
-
downgrade = True
|
| 563 |
-
chain.append(('联立','周线上涨 + 日线卖点 → 第44课: 小级别(日线)顶背驰未必引发大级别(周线)转折, 卖点降级为"减仓/短差"',
|
| 564 |
-
'第24课: 日线背驰除非周线同时背驰,否则不制造周线大顶'))
|
| 565 |
-
elif weekly_dir == 'down_trend' and is_buy:
|
| 566 |
-
if daily_sig.kind in ('B1','B2'):
|
| 567 |
-
chain.append(('联立','周线下跌 + 日线一/二买 → 下跌趋势的转折尝试, 必须由30分钟严格印证','第29课: 下跌的转折=上涨或盘整, 由背驰导致'))
|
| 568 |
-
else:
|
| 569 |
-
blocked = '周线下跌趋势中出现日线三买 —— 第43课: 三买属上涨结构, 与周线下跌矛盾, 大概率是下跌中继的假三买'
|
| 570 |
-
elif monthly_dir == 'down_trend' and is_buy and daily_sig.kind == 'B3' and not blocked:
|
| 571 |
-
blocked = '月线下跌大方向中出现日线三买 —— 第43/69课: 三买属上涨结构, 与月线大方向矛盾, 大概率假三买'
|
| 572 |
-
elif weekly_dir == 'up_trend' and is_buy:
|
| 573 |
-
chain.append(('联立','周线上涨 + 日线买点 → 方向一致, 进入30分钟精确定位','第43课: 大小级别同向, 操作最顺'))
|
| 574 |
-
elif weekly_dir == 'down_trend' and is_sell:
|
| 575 |
-
chain.append(('联立','周线下跌 + 日线卖点 → 方向一致, 趋势性卖出','第43课: 大小级别同向'))
|
| 576 |
-
elif weekly_dir == 'consolidation':
|
| 577 |
-
downgrade = True
|
| 578 |
-
chain.append(('联立','周线盘整 → 日线信号有效但降级(盘整中买卖点力度弱)','第29课: 盘整中的转折力度弱于趋势'))
|
| 579 |
-
|
| 580 |
-
# ── 周线二买 → 日线一买精确定位锚 (第14/17课) ──
|
| 581 |
-
if is_buy and wv is not None and wv.signal is not None \
|
| 582 |
-
and getattr(wv.signal, 'kind', '') == 'B2':
|
| 583 |
-
_dex = (daily_sig.extras or {}) if daily_sig is not None else {}
|
| 584 |
-
_anchor = _dex.get('b1_price') or _dex.get('cur_low')
|
| 585 |
-
if _anchor:
|
| 586 |
-
daily_sig.extras = dict(_dex)
|
| 587 |
-
daily_sig.extras['weekly_b2_daily_b1_anchor'] = float(_anchor)
|
| 588 |
-
chain.append(('联立',
|
| 589 |
-
f'✓ 周线二买 + 日线买点共振: 周线B2由日线一买构成(第14课), '
|
| 590 |
-
f'定位锚=日线一买位¥{float(_anchor):.3f}, 跌破该锚则周线二买证伪',
|
| 591 |
-
'第17课: 大级别买点的精确定位要落到次级别的具体买点价位'))
|
| 592 |
-
else:
|
| 593 |
-
chain.append(('联立',
|
| 594 |
-
'周线二买成立但日线尚无可定位的一买锚 → 等日线给出一买价位再重仓',
|
| 595 |
-
'第14课'))
|
| 596 |
-
|
| 597 |
-
if blocked:
|
| 598 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 599 |
-
action='WATCH', confidence='NONE', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 600 |
-
chain=chain, blocked_reason=blocked, note='周线方向闸门拦截, 不操作',
|
| 601 |
-
diagnostics=diagnostics, monthly=mov)
|
| 602 |
-
|
| 603 |
-
m60_an = _get_m60()
|
| 604 |
-
ok60 = False
|
| 605 |
-
if m60_an is not None:
|
| 606 |
-
if is_buy:
|
| 607 |
-
ok60, note60 = self._confirm_finer(m60_an, True, '60分钟', '日线', daily_sig.kind)
|
| 608 |
-
else:
|
| 609 |
-
ok60, note60 = self._confirm_finer(m60_an, False, '60分钟', '日线', daily_sig.kind,
|
| 610 |
-
parent_sig=daily_sig)
|
| 611 |
-
chain.append(('60m', ('✓ 次级别确认: ' if ok60 else '✗ 次级别未确认(降级): ')+note60,
|
| 612 |
-
'第44课: 区间套 —— 日线的转折先由直接次级别60分钟走势确认'))
|
| 613 |
-
if not ok60:
|
| 614 |
-
downgrade = True
|
| 615 |
-
if self.CFG.get('require_60m_buy_confirm') and is_buy and daily_sig.kind in ('B1', 'B2'):
|
| 616 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 617 |
-
action='WATCH', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 618 |
-
chain=chain, blocked_reason='60分钟直接次级别未印证买点(L44区间套)',
|
| 619 |
-
note=f'日线{daily_sig.kind}买点但60m未现B1/B2 → 等直接次级别印证再动手',
|
| 620 |
-
diagnostics=diagnostics, monthly=mov)
|
| 621 |
-
else:
|
| 622 |
-
if df_6 is not None:
|
| 623 |
-
chain.append(('60m', '60分钟数据不足(<30根) → 跳过该级印证', '第44课: 区间套逐级确认'))
|
| 624 |
-
|
| 625 |
-
m30_an = _get_m30()
|
| 626 |
-
if m30_an is None:
|
| 627 |
-
chain.append(('30m','无30分钟数据 → 缺少次级别精确印证, 信号降级','第44课: 操作级别的买卖点需次级别走势确认'))
|
| 628 |
-
confirmed = False; confirm_note = '缺30分钟数据'; downgrade = True
|
| 629 |
-
else:
|
| 630 |
-
if is_buy:
|
| 631 |
-
confirmed, confirm_note = self._confirm_30m_for_buy(m30_an, daily_sig.kind)
|
| 632 |
-
else:
|
| 633 |
-
confirmed, confirm_note = self._confirm_30m_for_sell(m30_an, daily_sig.kind,
|
| 634 |
-
parent_sig=daily_sig)
|
| 635 |
-
chain.append(('30m', ('✓ 次级别确认: ' if confirmed else '✗ 次级别未确认: ')+confirm_note, '第44课: 小背驰-大转折定理(必要条件)'))
|
| 636 |
-
|
| 637 |
-
# ── 60m嵌套印证补位 (第44课区间套, 卖点版) ──
|
| 638 |
-
# 30m动能未竭但60m(日线的直接次级别)已给出嵌套顶背驰/转折确认 → 采信60m。
|
| 639 |
-
# 日线的"次级别"本就是60m; 30m是60m的次级别, 不应让更细级别一票否决直接次级别。
|
| 640 |
-
if (self.CFG.get('sell_nested_interval') and is_sell and not confirmed
|
| 641 |
-
and ok60 and daily_sig.kind in ('S1', 'S2')):
|
| 642 |
-
confirmed = True
|
| 643 |
-
confirm_note = '30m动能未竭, 但60m(日线直接次级别)已嵌套印证 → 采信60m(第44课: 逐级处理, 直接次级别优先)'
|
| 644 |
-
chain.append(('联立', '✓ ' + confirm_note, '第44课区间套'))
|
| 645 |
-
|
| 646 |
-
# ── L50 MACD级别选择: 选黄白线和柱子清晰的级别看MACD ──
|
| 647 |
-
if (self.CFG.get('l50_macd_level_select') and is_buy and not confirmed
|
| 648 |
-
and ok60 and m60_an is not None and m30_an is not None):
|
| 649 |
-
try:
|
| 650 |
-
c60 = m60_an.macd_clarity(); c30 = m30_an.macd_clarity()
|
| 651 |
-
if c30['score'] < 0.4 and c60['score'] >= max(0.4, c30['score'] * 1.5):
|
| 652 |
-
confirmed = True
|
| 653 |
-
chain.append(('联立',
|
| 654 |
-
f"✓ L50 MACD级别选择: 30m MACD{c30['label']}(清晰度{c30['score']}) 判据不可靠; "
|
| 655 |
-
f"60m MACD{c60['label']}(清晰度{c60['score']})且60m已印证买点 → 采信60m结论",
|
| 656 |
-
'第50课: 看MACD要选黄白线和柱子走势清晰的级别'))
|
| 657 |
-
except Exception:
|
| 658 |
-
pass
|
| 659 |
-
|
| 660 |
-
m15_an = None; m15_confirmed = None
|
| 661 |
-
if confirmed and df_15 is not None and len(df_15) >= 30:
|
| 662 |
-
try: m15_an = _MAKE_ANALYZER('15m', df_15)
|
| 663 |
-
except Exception: m15_an = None
|
| 664 |
-
if not confirmed:
|
| 665 |
-
pass
|
| 666 |
-
elif df_15 is None:
|
| 667 |
-
pass
|
| 668 |
-
elif m15_an is None:
|
| 669 |
-
chain.append(('15m','15分钟数据不足(<30根) → 跳过该级印证','第44课: 区间套逐级确认'))
|
| 670 |
-
else:
|
| 671 |
-
ok15, note15 = self._confirm_finer(m15_an, is_buy, '15分钟', '30分钟', daily_sig.kind,
|
| 672 |
-
parent_sig=(daily_sig if is_sell else None))
|
| 673 |
-
m15_confirmed = ok15
|
| 674 |
-
chain.append(('15m', ('✓ 次级别确认: ' if ok15 else '✗ 次级别未确认(降级,不拦截): ')+note15,
|
| 675 |
-
'第44课: 逐级处理 —— 30分钟的转折由15分钟走势确认'))
|
| 676 |
-
if not ok15: downgrade = True
|
| 677 |
-
|
| 678 |
-
m5_an = None; m5_confirmed = None
|
| 679 |
-
if confirmed and df_5 is not None and len(df_5) >= 30:
|
| 680 |
-
try: m5_an = _MAKE_ANALYZER('5m', df_5)
|
| 681 |
-
except Exception: m5_an = None
|
| 682 |
-
if not confirmed:
|
| 683 |
-
pass
|
| 684 |
-
elif df_5 is None:
|
| 685 |
-
chain.append(('5m','无5分钟数据 → 缺该级逐级印证, 信号降级','第44课: 15分钟的转折需5分钟走势逐级确认')); downgrade = True
|
| 686 |
-
elif m5_an is None:
|
| 687 |
-
chain.append(('5m','5分钟数据不足(<30根) → 缺该级印证, 信号降级','第44课: 15分钟的转折需5分钟走势逐级确认')); downgrade = True
|
| 688 |
-
else:
|
| 689 |
-
ok5, note5 = self._confirm_5m(m5_an, is_buy, daily_sig.kind,
|
| 690 |
-
parent_sig=(daily_sig if is_sell else None))
|
| 691 |
-
m5_confirmed = ok5
|
| 692 |
-
chain.append(('5m', ('✓ 次级别确认: ' if ok5 else '✗ 次级别未确认(降级,不拦截): ')+note5, '第44课: 逐级处理 —— 15分钟的转折由5分钟走势确认'))
|
| 693 |
-
if not ok5: downgrade = True
|
| 694 |
-
|
| 695 |
-
m1_an = None; m1_confirmed = None
|
| 696 |
-
do_1m = confirmed and (m5_confirmed is True)
|
| 697 |
-
if do_1m and df_1 is not None and len(df_1) >= 30:
|
| 698 |
-
try: m1_an = _MAKE_ANALYZER('1m', df_1)
|
| 699 |
-
except Exception: m1_an = None
|
| 700 |
-
if not do_1m:
|
| 701 |
-
pass
|
| 702 |
-
elif df_1 is None:
|
| 703 |
-
chain.append(('1m','无1分钟数据 → 缺区间套最末级印证, 信号降级','第44课: 区间套 —— 5分钟的转折需1分钟走势逐级确认')); downgrade = True
|
| 704 |
-
elif m1_an is None:
|
| 705 |
-
chain.append(('1m','1分钟数据不足(<30根) → 缺最末级印证, 信号降级','第44课: 区间套 —— 5分钟的转折需1分钟走势逐级确认')); downgrade = True
|
| 706 |
-
else:
|
| 707 |
-
ok1, note1 = self._confirm_1m(m1_an, is_buy, daily_sig.kind,
|
| 708 |
-
parent_sig=(daily_sig if is_sell else None))
|
| 709 |
-
m1_confirmed = ok1
|
| 710 |
-
chain.append(('1m', ('✓ 末级确认: ' if ok1 else '✗ 末级未确认(降级,不拦截): ')+note1, '第44课: 区间套 —— 5分钟的转折由1分钟走势确认'))
|
| 711 |
-
if not ok1: downgrade = True
|
| 712 |
-
|
| 713 |
-
# ── L24规则: 周线上涨 + 日线【盘整背驰】(非趋势背驰)的S1/S2 ──
|
| 714 |
-
# 第24课: 某级别的背驰导致该级别的转折; 日线背驰若周线未同步背驰,
|
| 715 |
-
# 只构成日线级别的调整, 不是大顶 → 不全清仓, 转入区间套布防做短差:
|
| 716 |
-
# 真转折由布防线(嵌套背驰/破30m中枢ZD/峰值回落)兜底, 趋势延续则由
|
| 717 |
-
# 撤防机制(强势新高消化背驰, 第26课)继续骑中枢上移(第91课①)。
|
| 718 |
-
if (self.CFG.get('sell_nested_interval') and self.CFG.get('l24_weekly_uptrend_arm')
|
| 719 |
-
and is_sell and daily_sig.kind in ('S1', 'S2') and weekly_dir == 'up_trend'):
|
| 720 |
-
_dg = getattr(daily_sig, 'diverge_grade', None)
|
| 721 |
-
if _dg is not None and not getattr(_dg, 'is_trend_divergence', False):
|
| 722 |
-
_arm_zd = (m30_an.pivots[-1].zd if (m30_an is not None and m30_an.n_pivots) else None)
|
| 723 |
-
_ex = daily_sig.extras or {}
|
| 724 |
-
_arm_high = _ex.get('c_high') or _ex.get('prev_high') or cur_price
|
| 725 |
-
chain.append(('L24', f'周线上涨 + 日线{daily_sig.kind}仅为盘整背驰(非趋势背驰) '
|
| 726 |
-
f'→ 第24课: 不构成周线级别大顶, 不全清 → 转区间套布防短差'
|
| 727 |
-
f'(布防线: 30m中枢ZD{f"¥{_arm_zd:.3f}" if _arm_zd else "暂无"}/'
|
| 728 |
-
f'峰值回落{SELL_ARM_PEAK_DROP:.0%}; 强势新高则撤防骑趋势)',
|
| 729 |
-
'第24课: 日线背驰除非周线同步背驰, 否则只造成日线级别调整'))
|
| 730 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 731 |
-
action='HOLD', confidence='MEDIUM', final_kind=daily_sig.kind,
|
| 732 |
-
cur_price=cur_price, chain=chain,
|
| 733 |
-
note=f'L24: 周线上涨中的日线盘整背驰{daily_sig.kind}, 布防短差不全清',
|
| 734 |
-
sell_armed=True, arm_zd=_arm_zd,
|
| 735 |
-
arm_high=float(_arm_high) if _arm_high else None,
|
| 736 |
-
diagnostics=diagnostics, monthly=mov)
|
| 737 |
-
|
| 738 |
-
action = 'BUY' if is_buy else 'SELL'
|
| 739 |
-
if self.CFG.get('mode') == 'long' and is_sell and daily_sig.kind in ('S1', 'S2'):
|
| 740 |
-
big_up_long = ((wv is not None and wv.trend == 'up_trend') or
|
| 741 |
-
(mov is not None and monthly_dir == 'up_trend'))
|
| 742 |
-
ma5m_ok = True
|
| 743 |
-
if len(df_mo) >= 5:
|
| 744 |
-
ma5m = float(df_mo['close'].tail(5).mean())
|
| 745 |
-
ma5m_ok = cur_price >= ma5m
|
| 746 |
-
if big_up_long and ma5m_ok:
|
| 747 |
-
chain.append(('mode', f'长线模式: 日线{daily_sig.kind}非周线级别转折且大方向上涨 → 持有(操作级别=周线)', '第72课: 看周线则日线调整不构成卖段; 第61课大级别不因小级别震荡卖'))
|
| 748 |
-
# 长线模式持有也挂上布防线(第44课): 真转折时不至于全程坐滑梯
|
| 749 |
-
_arm_zd = (m30_an.pivots[-1].zd if (m30_an is not None and m30_an.n_pivots) else None)
|
| 750 |
-
_ex = daily_sig.extras or {}
|
| 751 |
-
_arm_high = _ex.get('c_high') or _ex.get('prev_high') or cur_price
|
| 752 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 753 |
-
action='HOLD', confidence='MEDIUM', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 754 |
-
chain=chain, note=f'长线模式持有: 日线{daily_sig.kind}属次级别调整, 周线方向未转',
|
| 755 |
-
sell_armed=bool(self.CFG.get('sell_nested_interval')),
|
| 756 |
-
arm_zd=_arm_zd, arm_high=float(_arm_high) if _arm_high else None,
|
| 757 |
-
diagnostics=diagnostics, monthly=mov)
|
| 758 |
-
if not confirmed:
|
| 759 |
-
if is_sell:
|
| 760 |
-
# ── 卖点区间套布防 (第24/44课): 背驰已现但次级别动能未竭 ──
|
| 761 |
-
# 不立即卖(防卖飞), 也绝不傻等到三卖(防坐滑梯): 持仓转入布防,
|
| 762 |
-
# 由回测端的布防线(嵌套背驰出现/破30m中枢ZD/峰值回落阈值)收割。
|
| 763 |
-
# 布防仅用于S1的精确定顶。S2是一卖后的【反抽】卖点(第15课):
|
| 764 |
-
# 上方空间被一卖高点封死, 反抽本身就是用来卖的, "等次级别动能衰竭"
|
| 765 |
-
# 与其性质矛盾 → S2未印证时维持"先卖再说"(走下方LOW置信卖出)。
|
| 766 |
-
if (self.CFG.get('sell_nested_interval') and daily_sig.kind == 'S1'):
|
| 767 |
-
arm_zd = (m30_an.pivots[-1].zd if (m30_an is not None and m30_an.n_pivots) else None)
|
| 768 |
-
ex = daily_sig.extras or {}
|
| 769 |
-
arm_high = ex.get('c_high') or ex.get('prev_high') or cur_price
|
| 770 |
-
chain.append(('卖点布防',
|
| 771 |
-
f'日线{daily_sig.kind}背驰已现但次级别动能未竭 → 不立即卖(防卖飞), '
|
| 772 |
-
f'转入区间套布防: ①次级别出现嵌套顶背驰即卖 '
|
| 773 |
-
f'②跌破30m最近中枢下沿ZD{f"¥{arm_zd:.3f}" if arm_zd else "(暂无30m中枢)"}即卖 '
|
| 774 |
-
f'③较布防后峰值回落{SELL_ARM_PEAK_DROP:.0%}即卖',
|
| 775 |
-
'第44课区间套: 背驰段中套背驰段定精确高点; 第24课: 次级别未背驰, 大级别顶未到'))
|
| 776 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 777 |
-
action='HOLD', confidence='MEDIUM', final_kind=daily_sig.kind,
|
| 778 |
-
cur_price=cur_price, chain=chain,
|
| 779 |
-
note=f'区间套布防中: 日线{daily_sig.kind}背驰段内, 等次级别嵌套背驰收敛后高位兑现(第44课)',
|
| 780 |
-
sell_armed=True, arm_zd=arm_zd,
|
| 781 |
-
arm_high=float(arm_high) if arm_high else None,
|
| 782 |
-
confidence_reasons=[f'布防原因: {confirm_note}'],
|
| 783 |
-
diagnostics=diagnostics, monthly=mov)
|
| 784 |
-
big_up_sell = ((wv is not None and wv.trend == 'up_trend') or
|
| 785 |
-
(mov is not None and monthly_dir == 'up_trend'))
|
| 786 |
-
if self.CFG.get('require_sublevel_sell_confirm') and big_up_sell:
|
| 787 |
-
chain.append(('sell_gate', f'日线{daily_sig.kind}卖点但次级别未印证, 且大方向上涨 → 持有(L61: 大级别不因小级别震荡卖)', '第61课: 区间套确认; 大级别操作不因小级别震荡清仓'))
|
| 788 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 789 |
-
action='HOLD', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 790 |
-
chain=chain, note=f'日线{daily_sig.kind}卖点次级别未印证+大方向上涨, 持有等确认: {confirm_note}',
|
| 791 |
-
confidence_reasons=[f'次级别未印证持有: {confirm_note}'],
|
| 792 |
-
diagnostics=diagnostics, monthly=mov)
|
| 793 |
-
chain.append(('sell_gate',f'日线{daily_sig.kind}卖点已成立, 次级别未印证只降级不拦截','第14/15/21课: 买点买、卖点卖; 区间套用于精确定位, 不是否决卖点'))
|
| 794 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 795 |
-
action='SELL', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 796 |
-
chain=chain, note=f'日线{daily_sig.kind}卖点先执行; 次级别未印证仅降级: {confirm_note}',
|
| 797 |
-
confidence_reasons=[f'次级别未印证: {confirm_note}'],
|
| 798 |
-
diagnostics=diagnostics, monthly=mov)
|
| 799 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 800 |
-
action='WATCH', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 801 |
-
chain=chain, blocked_reason=f'次级别未印证({confirm_note})',
|
| 802 |
-
note='日线有买/非S1卖信号但次级别未确认 -- 等次级别出信号再动手',
|
| 803 |
-
diagnostics=diagnostics, monthly=mov)
|
| 804 |
|
| 805 |
-
score = 100; conf_reasons = []
|
| 806 |
-
def _penalty(pts, reason):
|
| 807 |
-
nonlocal score
|
| 808 |
-
score -= pts; conf_reasons.append(f'(-{pts}) {reason}')
|
| 809 |
-
if mov is not None and monthly_dir == 'down_trend' and is_buy:
|
| 810 |
-
_penalty(25, '第69课: 月线大方向下跌, 日线买点属逆大方向的转折尝试')
|
| 811 |
-
trend_piv_lt2 = (daily_an is not None and daily_an.n_trend_pivots < 2)
|
| 812 |
-
if trend_piv_lt2 and daily_sig.kind in ('B1', 'S1'):
|
| 813 |
-
_penalty(20, f'第27课: 背驰段前仅{daily_an.n_trend_pivots}个中枢(<2), 盘整背驰非趋势背驰')
|
| 814 |
-
if daily_an is not None and daily_an.trend == 'consolidation':
|
| 815 |
-
_penalty(15, '第29课: 日线处盘整结构, 盘整中转折力度弱')
|
| 816 |
-
daily_dg = getattr(daily_sig, 'diverge_grade', None)
|
| 817 |
-
if daily_dg is not None and daily_dg.grade == 'WEAK':
|
| 818 |
-
trig = '面积' if daily_dg.area_ok else 'DIF极值'
|
| 819 |
-
_penalty(15, f'第27课: 背驰仅满足{trig}单一判据(WEAK), 非标准背驰')
|
| 820 |
-
if (daily_dg is not None and daily_dg.grade in ('STRONG', 'WEAK')
|
| 821 |
-
and not daily_dg.is_trend_divergence and not trend_piv_lt2
|
| 822 |
-
and daily_sig.kind in ('B1', 'S1')):
|
| 823 |
-
_penalty(10, '第27课: 背驰段为盘整背驰(非趋势背驰), 力度偏弱')
|
| 824 |
-
if downgrade:
|
| 825 |
-
_penalty(12, '第44课: 区间套次级别未完全印证(精度降级, 非证伪)')
|
| 826 |
-
if daily_sig.kind in ('B2', 'S2'):
|
| 827 |
-
has_delayed = any(lvl in ('30m', '15m', '5m', '1m') and '延后确认' in concl
|
| 828 |
-
for lvl, concl, _ in chain)
|
| 829 |
-
if has_delayed:
|
| 830 |
-
_penalty(8, '二/二卖由次级别延后确认(非次级别一买/一卖精确定位), 精度略降')
|
| 831 |
-
ex = daily_sig.extras or {}
|
| 832 |
-
if daily_sig.kind == 'B3':
|
| 833 |
-
missing = []
|
| 834 |
-
if ex.get('b1_price') is None:
|
| 835 |
-
missing.append('一买')
|
| 836 |
-
if ex.get('b2_price') is None:
|
| 837 |
-
missing.append('二买')
|
| 838 |
-
if missing:
|
| 839 |
-
_penalty(6, '第20/21课: 三买未能定位对应' + '/'.join(missing) + ', 质量略降')
|
| 840 |
-
if ex.get('late_trend_b3'):
|
| 841 |
-
_penalty(12, '第20/92课: 第二个以上同向中枢后的三买, 操作意义下降')
|
| 842 |
-
if daily_sig.kind == 'S3':
|
| 843 |
-
missing = []
|
| 844 |
-
if ex.get('s1_price') is None:
|
| 845 |
-
missing.append('一卖')
|
| 846 |
-
if ex.get('s2_price') is None:
|
| 847 |
-
missing.append('二卖')
|
| 848 |
-
if missing:
|
| 849 |
-
_penalty(6, '第20/21课: 三卖未能定位对应' + '/'.join(missing) + ', 质量略降')
|
| 850 |
-
if daily_dg is not None and daily_dg.grade == 'STRONG' and daily_dg.is_trend_divergence:
|
| 851 |
-
score += 10; conf_reasons.append('(+10) 第27课: 标准趋势背驰(>=2中枢+面积+DIF), 高质量')
|
| 852 |
-
# ── 卖点区间套加分: 30m嵌套顶背驰精确定位(卖在高位区) ──
|
| 853 |
-
if is_sell and any(lvl == '30m' and '嵌套顶背驰' in concl for lvl, concl, _ in chain):
|
| 854 |
-
score += 8; conf_reasons.append('(+8) 第44课: 30m嵌套顶背驰精确定位, 卖点落在高位区')
|
| 855 |
-
score = max(0, min(110, score))
|
| 856 |
-
confidence = 'HIGH' if score >= 70 else ('MEDIUM' if score >= 45 else 'LOW')
|
| 857 |
-
if conf_reasons:
|
| 858 |
-
chain.append(('置信度', f'{confidence}(评分{score}) ← ' + '; '.join(conf_reasons),
|
| 859 |
-
'第21课: 一二三类买卖点是位置分类非质量排序; 置信度按原著力度条件评分'))
|
| 860 |
-
else:
|
| 861 |
-
chain.append(('置信度', f'HIGH(评分{score}) ← 力度条件全部满足',
|
| 862 |
-
'第27/29/43课: 方向一致+趋势背驰+趋势结构'))
|
| 863 |
-
note_parts = []
|
| 864 |
-
if conf_reasons:
|
| 865 |
-
note_parts.append(f'置信评分明细: ' + '; '.join(conf_reasons))
|
| 866 |
-
n_lvl = 3 + (self.df_60m is not None) + (self.df_15m is not None) + (self.df_5m is not None) + (self.df_1m is not None)
|
| 867 |
-
lvl_txt = {3:'三级别',4:'四级别',5:'五级别',6:'六级别',7:'七级别'}.get(n_lvl, f'{n_lvl}级别')
|
| 868 |
-
m5_txt = ('/5m已印证' if m5_confirmed is True else '/5m未印证(已降级)' if m5_confirmed is False else '')
|
| 869 |
-
m1_txt = ('/1m已印证' if m1_confirmed is True else '/1m未印证(已降级)' if m1_confirmed is False else '')
|
| 870 |
-
note_parts.append(f'{lvl_txt}联立通过: 周线{weekly_dir}/日线{daily_sig.kind}/30m已印证{m5_txt}{m1_txt}')
|
| 871 |
-
note = ' | '.join(note_parts)
|
| 872 |
-
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 873 |
-
action=action, confidence=confidence, final_kind=daily_sig.kind, cur_price=cur_price,
|
| 874 |
-
chain=chain, note=note, confidence_reasons=conf_reasons, diagnostics=diagnostics, monthly=mov)
|
| 875 |
|
| 876 |
-
|
| 877 |
-
|
| 878 |
-
|
| 879 |
-
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
chan_enhance.py —— 缠论系统增强(查漏补缺) · 纯函数版(非Hook)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
"""
|
| 4 |
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
W_REVERSAL = 1.0
|
| 8 |
+
W_TRAP = 0.85
|
| 9 |
+
W_RELAY = 0.6
|
| 10 |
+
W_UNKNOWN = 0.7
|
| 11 |
+
|
| 12 |
+
# 第16课 六种基本走势 → 买法分类的中文名(配合英文 method 一起展示)
|
| 13 |
+
METHOD_CN = {
|
| 14 |
+
'reversal': '反转式(下跌+盘整+上涨 / 趋势底背驰一买, 力度最强, 权重最高)',
|
| 15 |
+
'trap': '陷阱式(下跌+上涨, 空头陷阱式二买, 中等力度)',
|
| 16 |
+
'relay': '中继式(上涨+盘整+上涨, 三买, 趋势中继)',
|
| 17 |
+
'unknown': '未分类',
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
WEAK_ARM_GAIN = 0.04
|
| 21 |
+
WEAK_GIVEBACK = 0.22
|
| 22 |
+
|
| 23 |
+
L92_EXIT_ENABLE = False
|
| 24 |
+
L92_EXIT_FLOATING_MAX = 0.015
|
| 25 |
+
L92_ZD_BREAK_BUF = 0.0
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def classify_buy_method(sig_kind, ml) -> str:
|
| 29 |
+
sig = ml.daily.signal if (ml and ml.daily) else None
|
| 30 |
+
dg = getattr(sig, 'diverge_grade', None) if sig is not None else None
|
| 31 |
+
if sig_kind == 'B1':
|
| 32 |
+
return 'reversal'
|
| 33 |
+
if sig_kind == 'B3':
|
| 34 |
+
return 'relay'
|
| 35 |
+
if sig_kind == 'B2':
|
| 36 |
+
# L37 背驰分辨: 只有"趋势背驰"(≥2个同向中枢后的背驰)才是标准的趋势转折,
|
| 37 |
+
# 对应第16课"下跌+盘整+上涨"反转式买法; 盘整背驰力度弱, 仍按陷阱式处理。
|
| 38 |
+
if (dg is not None and getattr(dg, 'grade', '') == 'STRONG'
|
| 39 |
+
and getattr(dg, 'is_trend_divergence', False)):
|
| 40 |
+
return 'reversal'
|
| 41 |
+
return 'trap'
|
| 42 |
+
return 'unknown'
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def l37_divergence_note(ml) -> str:
|
| 46 |
+
"""L37 背驰分辨(区分背驰段构成): 趋势背驰=至少两个同向中枢后的背驰, 转折级别高;
|
| 47 |
+
盘整背驰=单中枢内的力度衰竭, 只保证回拉中枢, 不保证反转。"""
|
| 48 |
+
sig = ml.daily.signal if (ml and ml.daily) else None
|
| 49 |
+
dg = getattr(sig, 'diverge_grade', None) if sig is not None else None
|
| 50 |
+
if dg is None or getattr(dg, 'grade', 'NONE') == 'NONE':
|
| 51 |
+
return ''
|
| 52 |
+
if getattr(dg, 'is_trend_divergence', False):
|
| 53 |
+
return (f'第37课: 趋势背驰({getattr(dg, "n_trend_pivots", "?")}个同向中枢), '
|
| 54 |
+
f'转折至少回拉至最后一个中枢, 大概率反转 → 可按反转式买法重仓')
|
| 55 |
+
return ('第37课: 盘整背驰(背驰段内仅1个中枢), 只保证回拉中枢一次, '
|
| 56 |
+
'不保证趋势反转 → 轻仓短打, 回拉到中枢即考虑兑现')
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def buy_method_weight(sig_kind, ml):
|
| 60 |
+
method = classify_buy_method(sig_kind, ml)
|
| 61 |
+
wmap = {'reversal': W_REVERSAL, 'trap': W_TRAP, 'relay': W_RELAY, 'unknown': W_UNKNOWN}
|
| 62 |
+
return method, wmap.get(method, W_UNKNOWN)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def recompute_evo(ml) -> str:
|
| 66 |
+
sig = ml.daily.signal if (ml and ml.daily) else None
|
| 67 |
+
if sig is not None:
|
| 68 |
+
evo = (sig.extras or {}).get('post_evolution', '')
|
| 69 |
+
if evo:
|
| 70 |
+
return evo
|
| 71 |
+
dv = ml.daily if ml else None
|
| 72 |
+
if dv is not None and dv.zd is not None and dv.zg is not None:
|
| 73 |
+
if ml.cur_price < dv.zd:
|
| 74 |
+
return 'case1_extend'
|
| 75 |
+
return ''
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def weak_evo_giveback(pos, ml, default_giveback):
|
| 79 |
+
evo = recompute_evo(ml) if ml is not None else (pos.get('post_evo') or '')
|
| 80 |
+
if evo == 'case1_extend':
|
| 81 |
+
return WEAK_ARM_GAIN, WEAK_GIVEBACK
|
| 82 |
+
return None, default_giveback
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def l92_should_exit(pos, ml, close_p, floating):
|
| 86 |
+
if not L92_EXIT_ENABLE or ml is None or ml.daily is None:
|
| 87 |
+
return None
|
| 88 |
+
if floating > L92_EXIT_FLOATING_MAX:
|
| 89 |
+
return None
|
| 90 |
+
zd = ml.daily.zd
|
| 91 |
+
if zd is None:
|
| 92 |
+
return None
|
| 93 |
+
if close_p < zd * (1 - L92_ZD_BREAK_BUF) and close_p > pos.get('stop_px', 0):
|
| 94 |
+
return ('L92_EXIT',
|
| 95 |
+
f'第92课中枢震荡监视器: 现价{close_p:.3f}已破日线中枢下沿ZD{zd:.3f}'
|
| 96 |
+
f'(向下变盘) 且浮盈仅{floating:+.1%} → 提前减仓, 不等磨到结构止损')
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def predict_enhance(ml) -> dict:
|
| 101 |
+
out = {}
|
| 102 |
+
if ml is None or ml.daily is None:
|
| 103 |
+
return out
|
| 104 |
+
sig_kind = ml.final_kind or ''
|
| 105 |
+
if sig_kind in ('B1', 'B2', 'B3'):
|
| 106 |
+
method, weight = buy_method_weight(sig_kind, ml)
|
| 107 |
+
out['buy_method'] = method
|
| 108 |
+
out['buy_method_cn'] = METHOD_CN.get(method, method)
|
| 109 |
+
out['suggest_weight'] = weight
|
| 110 |
+
out['l16_note'] = f'第16课: 买法分类={method}〔{METHOD_CN.get(method, method)}〕, 建议仓位权重{weight:.2f}'
|
| 111 |
+
l37 = l37_divergence_note(ml)
|
| 112 |
+
if l37:
|
| 113 |
+
out['l37_note'] = l37
|
| 114 |
+
evo = recompute_evo(ml)
|
| 115 |
+
if evo:
|
| 116 |
+
out['post_evolution'] = evo
|
| 117 |
+
out['evo_hint'] = ('第29课: 最弱形态(反弹/回落未回中枢), 持有宜收紧移动止盈, 尽快兑现'
|
| 118 |
+
if evo == 'case1_extend'
|
| 119 |
+
else '第29课: 形态较强(回到中枢), 可持有等三买/趋势延续')
|
| 120 |
+
zd = ml.daily.zd
|
| 121 |
+
if zd is not None and ml.cur_price < zd:
|
| 122 |
+
out['l92_warn'] = f'第92课: 现价{ml.cur_price:.3f}已在日线中枢下沿ZD{zd:.3f}下方 → 向下变盘预警'
|
| 123 |
+
return out
|
data_us.py
CHANGED
|
@@ -1,147 +1,70 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 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
|
| 23 |
-
import
|
| 24 |
-
import traceback
|
| 25 |
|
| 26 |
import pandas as pd
|
| 27 |
|
| 28 |
-
import
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def load_level(ticker: str, level: str, force: bool = False) -> pd.DataFrame:
|
| 82 |
-
"""Load one level for a ticker, using parquet cache when fresh."""
|
| 83 |
-
assert level in LEVELS, f"unknown level {level}"
|
| 84 |
-
path = _cache_path(ticker, level)
|
| 85 |
-
if not force and os.path.exists(path):
|
| 86 |
-
age = time.time() - os.path.getmtime(path)
|
| 87 |
-
if age < _STALE_SECONDS[level]:
|
| 88 |
-
try:
|
| 89 |
-
return pd.read_parquet(path)
|
| 90 |
-
except Exception:
|
| 91 |
-
pass
|
| 92 |
-
try:
|
| 93 |
-
import yfinance as yf
|
| 94 |
-
interval, period = LEVELS[level]
|
| 95 |
-
raw = yf.Ticker(ticker).history(period=period, interval=interval,
|
| 96 |
-
auto_adjust=True, actions=False)
|
| 97 |
-
df = _normalize(raw)
|
| 98 |
-
if len(df):
|
| 99 |
-
df.to_parquet(path, index=False)
|
| 100 |
-
return df
|
| 101 |
-
except Exception:
|
| 102 |
-
traceback.print_exc()
|
| 103 |
-
# network failed → fall back to stale cache if any
|
| 104 |
-
if os.path.exists(path):
|
| 105 |
-
try:
|
| 106 |
-
return pd.read_parquet(path)
|
| 107 |
-
except Exception:
|
| 108 |
-
pass
|
| 109 |
-
return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
# Full nested-interval set (区间套): the more sub-levels confirm, the more
|
| 113 |
-
# precise the buy/sell point. We fetch the deepest Yahoo allows. 1m has only
|
| 114 |
-
# 7 days of history — included when present, skipped gracefully otherwise.
|
| 115 |
-
# Downloads are parallel + cached, so the extra levels cost little wall-time.
|
| 116 |
-
FULL_LEVELS = ("d", "60m", "30m", "15m", "5m", "1m")
|
| 117 |
-
FAST_LEVELS = FULL_LEVELS # default everywhere; alias kept for older callers
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def load_levels(ticker: str, levels=FAST_LEVELS, force: bool = False) -> dict:
|
| 121 |
-
return {lvl: load_level(ticker, lvl, force=force) for lvl in levels}
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
def load_all_levels(ticker: str, force: bool = False) -> dict:
|
| 125 |
-
"""Return {'d':…, '60m':…, '30m':…, '15m':…, '5m':…} (1m intentionally absent)."""
|
| 126 |
-
return {lvl: load_level(ticker, lvl, force=force) for lvl in LEVELS}
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
def prefetch(tickers, levels=FAST_LEVELS, force: bool = False, workers: int = 5,
|
| 130 |
-
budget_s: int = 45):
|
| 131 |
-
"""Download all (ticker, level) pairs in parallel with a hard time budget.
|
| 132 |
-
Yahoo rate-limits datacenter IPs; without a budget one throttled request
|
| 133 |
-
could hang the whole Run-analysis click. Whatever isn't fetched in time is
|
| 134 |
-
skipped — the engine analyzes from daily/cached data and the next run
|
| 135 |
-
picks up the rest."""
|
| 136 |
-
from concurrent.futures import ThreadPoolExecutor, wait
|
| 137 |
-
jobs = [(t, lvl) for t in tickers for lvl in levels]
|
| 138 |
-
ex = ThreadPoolExecutor(max_workers=workers)
|
| 139 |
-
futs = [ex.submit(load_level, t, lvl, force) for t, lvl in jobs]
|
| 140 |
-
done, not_done = wait(futs, timeout=budget_s)
|
| 141 |
-
ex.shutdown(wait=False, cancel_futures=True)
|
| 142 |
-
return len(done), len(not_done)
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
def last_daily_date(ticker: str):
|
| 146 |
-
df = load_level(ticker, "d")
|
| 147 |
-
return None if df.empty else pd.Timestamp(df["date"].iloc[-1])
|
|
|
|
| 1 |
"""
|
| 2 |
+
chan_glue.py — wires the user's notebook-style modules together at runtime.
|
| 3 |
+
|
| 4 |
+
The original chan_engine.py / chan_multilevel.py were written as notebook cells:
|
| 5 |
+
chan_multilevel references `ChanAnalyzer` / `Signal` via a commented-out import.
|
| 6 |
+
Because both files use `from __future__ import annotations` and resolve names at
|
| 7 |
+
call time, we can simply inject the symbols into the module namespace — the Chan
|
| 8 |
+
analysis logic itself is left 100% untouched.
|
| 9 |
+
|
| 10 |
+
Also provides a small LRU-cached analyzer factory (the original chan_common.py
|
| 11 |
+
was A-share specific and is not used in the US version).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
+
import hashlib
|
| 16 |
+
from collections import OrderedDict
|
|
|
|
| 17 |
|
| 18 |
import pandas as pd
|
| 19 |
|
| 20 |
+
import chan_engine
|
| 21 |
+
import chan_multilevel
|
| 22 |
+
|
| 23 |
+
# ── inject cross-module symbols (replaces the commented `from chan_engine import …`) ──
|
| 24 |
+
chan_multilevel.ChanAnalyzer = chan_engine.ChanAnalyzer
|
| 25 |
+
chan_multilevel.Signal = chan_engine.Signal
|
| 26 |
+
chan_engine.PIVOT_MAX_EXTEND_SEGS = chan_engine.PIVOT_MAX_EXTEND_SEGS # no-op, keeps linters calm
|
| 27 |
+
|
| 28 |
+
# Re-exports for app code
|
| 29 |
+
ChanAnalyzer = chan_engine.ChanAnalyzer
|
| 30 |
+
Signal = chan_engine.Signal
|
| 31 |
+
MultiLevelChan = chan_multilevel.MultiLevelChan
|
| 32 |
+
MultiLevelSignal = chan_multilevel.MultiLevelSignal
|
| 33 |
+
resample_weekly = chan_multilevel.resample_weekly
|
| 34 |
+
resample_monthly = chan_multilevel.resample_monthly
|
| 35 |
+
set_analyzer_factory = chan_multilevel.set_analyzer_factory
|
| 36 |
+
|
| 37 |
+
# ── cached analyzer factory ──────────────────────────────────────────────
|
| 38 |
+
_CACHE: "OrderedDict[str, chan_engine.ChanAnalyzer]" = OrderedDict()
|
| 39 |
+
_CACHE_MAX = 64
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _df_key(level: str, df: pd.DataFrame) -> str:
|
| 43 |
+
n = len(df)
|
| 44 |
+
if n == 0:
|
| 45 |
+
return f"{level}-empty"
|
| 46 |
+
last = str(df['date'].iloc[-1])
|
| 47 |
+
first = str(df['date'].iloc[0])
|
| 48 |
+
tail_close = float(df['close'].iloc[-1])
|
| 49 |
+
raw = f"{level}|{n}|{first}|{last}|{tail_close:.6f}"
|
| 50 |
+
return hashlib.md5(raw.encode()).hexdigest()
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def cached_analyzer(level: str, df: pd.DataFrame):
|
| 54 |
+
key = _df_key(level, df)
|
| 55 |
+
if key in _CACHE:
|
| 56 |
+
_CACHE.move_to_end(key)
|
| 57 |
+
return _CACHE[key]
|
| 58 |
+
an = chan_engine.ChanAnalyzer(df.reset_index(drop=True))
|
| 59 |
+
_CACHE[key] = an
|
| 60 |
+
while len(_CACHE) > _CACHE_MAX:
|
| 61 |
+
_CACHE.popitem(last=False)
|
| 62 |
+
return an
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def install():
|
| 66 |
+
"""Install the cached analyzer factory into MultiLevelChan."""
|
| 67 |
+
set_analyzer_factory(cached_analyzer)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
install()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
elevation.css
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
BASE — Chan Compass · Spectrum 2
|
| 3 |
+
Light, opinionated element defaults. Body uses the canvas
|
| 4 |
+
background and UI type. Helper classes for the few primitives
|
| 5 |
+
that aren't worth a React component.
|
| 6 |
+
============================================================ */
|
| 7 |
+
|
| 8 |
+
*, *::before, *::after { box-sizing: border-box; }
|
| 9 |
+
|
| 10 |
+
body {
|
| 11 |
+
margin: 0;
|
| 12 |
+
background: var(--canvas);
|
| 13 |
+
color: var(--text-body);
|
| 14 |
+
font: var(--type-body);
|
| 15 |
+
-webkit-font-smoothing: antialiased;
|
| 16 |
+
text-rendering: optimizeLegibility;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
h1, h2, h3, h4 {
|
| 20 |
+
margin: 0;
|
| 21 |
+
color: var(--text-heading);
|
| 22 |
+
letter-spacing: var(--letter-spacing-heading);
|
| 23 |
+
text-wrap: balance;
|
| 24 |
+
}
|
| 25 |
+
h1 { font: var(--type-h1); }
|
| 26 |
+
h2 { font: var(--type-h2); }
|
| 27 |
+
h3 { font: var(--type-h3); letter-spacing: 0; }
|
| 28 |
+
h4 { font: var(--type-h4); letter-spacing: 0; }
|
| 29 |
+
|
| 30 |
+
p { margin: 0; text-wrap: pretty; }
|
| 31 |
+
|
| 32 |
+
a { color: var(--accent-text); text-decoration: none; }
|
| 33 |
+
a:hover { text-decoration: underline; text-underline-offset: 2px; }
|
| 34 |
+
|
| 35 |
+
code, kbd, samp { font-family: var(--font-code); font-size: 0.92em; }
|
| 36 |
+
|
| 37 |
+
::selection { background: var(--accent-subtle); }
|
| 38 |
+
|
| 39 |
+
:focus-visible {
|
| 40 |
+
outline: var(--focus-ring-width) solid var(--focus-ring);
|
| 41 |
+
outline-offset: var(--focus-ring-offset);
|
| 42 |
+
border-radius: var(--radius-sm);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/* ---- Eyebrow / overline ---- */
|
| 46 |
+
.s2-eyebrow {
|
| 47 |
+
font: var(--font-weight-bold) var(--font-size-50)/1.2 var(--font-sans);
|
| 48 |
+
letter-spacing: var(--letter-spacing-caps);
|
| 49 |
+
text-transform: uppercase;
|
| 50 |
+
color: var(--text-muted);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/* ---- Hairline divider ---- */
|
| 54 |
+
.s2-divider {
|
| 55 |
+
border: none;
|
| 56 |
+
border-top: var(--border-width-100) solid var(--border-hairline);
|
| 57 |
+
margin: var(--space-300) 0;
|
| 58 |
+
}
|
emailer.py
CHANGED
|
@@ -1,288 +1,884 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
|
|
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
import logging
|
| 19 |
-
import smtplib
|
| 20 |
-
import urllib.request
|
| 21 |
-
import urllib.error
|
| 22 |
-
import re
|
| 23 |
-
from datetime import datetime
|
| 24 |
-
from email.mime.text import MIMEText
|
| 25 |
-
from email.mime.multipart import MIMEMultipart
|
| 26 |
-
from email.header import Header
|
| 27 |
-
from email.utils import formataddr
|
| 28 |
-
|
| 29 |
-
logger = logging.getLogger("chan_emailer")
|
| 30 |
-
|
| 31 |
-
# ── Transport 1 (preferred on HF): Resend HTTPS API on port 443 ──
|
| 32 |
-
# HF Spaces block outbound SMTP ports (465/587) → SMTP fails with
|
| 33 |
-
# "Network is unreachable". The Resend REST API uses plain HTTPS, which Spaces
|
| 34 |
-
# allow. Set a Space secret RESEND_API_KEY to enable it. Free tier ≈ 100/day.
|
| 35 |
-
# Until you verify your own domain, Resend only lets you send FROM
|
| 36 |
-
# "onboarding@resend.dev" — that's the default sender below.
|
| 37 |
-
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
|
| 38 |
-
RESEND_FROM = os.environ.get("RESEND_FROM", "Chan Compass <onboarding@resend.dev>")
|
| 39 |
-
|
| 40 |
-
# ── Transport 2 (fallback, works off-HF): classic SMTP ──
|
| 41 |
-
EMAIL_SENDER = os.environ.get("CHAN_EMAIL_SENDER", "")
|
| 42 |
-
EMAIL_PASSWORD = os.environ.get("CHAN_EMAIL_PASSWORD", "")
|
| 43 |
-
|
| 44 |
-
SMTP_CONFIGS = {
|
| 45 |
-
"gmail.com": {"server": "smtp.gmail.com", "port": 465, "ssl": True},
|
| 46 |
-
"qq.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
|
| 47 |
-
"163.com": {"server": "smtp.163.com", "port": 465, "ssl": True},
|
| 48 |
-
"126.com": {"server": "smtp.126.com", "port": 465, "ssl": True},
|
| 49 |
-
"outlook.com": {"server": "smtp.office365.com", "port": 587, "ssl": False},
|
| 50 |
-
"hotmail.com": {"server": "smtp.office365.com", "port": 587, "ssl": False},
|
| 51 |
-
"foxmail.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
|
| 52 |
-
"sina.com": {"server": "smtp.sina.com", "port": 465, "ssl": True},
|
| 53 |
-
"aliyun.com": {"server": "smtp.aliyun.com", "port": 465, "ssl": True},
|
| 54 |
-
}
|
| 55 |
-
|
| 56 |
-
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def _sender_address(sender: str) -> str:
|
| 60 |
-
return formataddr((str(Header("Chan Compass", "utf-8")), sender))
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
def _md_to_plain(text: str) -> str:
|
| 64 |
-
"""Markdown -> readable plain text (for the text/plain MIME part and the
|
| 65 |
-
Resend `text` field). Strips #/**/| noise so text-only clients look clean."""
|
| 66 |
-
lines, out = text.split("\n"), []
|
| 67 |
-
i, n = 0, len(lines)
|
| 68 |
-
while i < n:
|
| 69 |
-
line = lines[i]
|
| 70 |
-
if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", lines[i + 1]):
|
| 71 |
-
header = [c.strip() for c in line.strip().strip("|").split("|")]
|
| 72 |
-
i += 2
|
| 73 |
-
while i < n and "|" in lines[i]:
|
| 74 |
-
cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
|
| 75 |
-
if len(header) == 2 and len(cells) == 2:
|
| 76 |
-
out.append(f" {cells[0]}: {cells[1]}")
|
| 77 |
-
else:
|
| 78 |
-
out.append(" " + " | ".join(cells))
|
| 79 |
-
i += 1
|
| 80 |
-
out.append("")
|
| 81 |
-
continue
|
| 82 |
-
m = re.match(r"^(#{1,4})\s+(.*)$", line)
|
| 83 |
-
if m:
|
| 84 |
-
title = m.group(2).strip()
|
| 85 |
-
out.append("")
|
| 86 |
-
out.append(title.upper() if len(m.group(1)) <= 2 else title)
|
| 87 |
-
out.append("-" * min(len(title), 60))
|
| 88 |
-
i += 1
|
| 89 |
-
continue
|
| 90 |
-
if line.strip() in ("---", "***", "___"):
|
| 91 |
-
out.append("-" * 40)
|
| 92 |
-
i += 1
|
| 93 |
-
continue
|
| 94 |
-
s = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
|
| 95 |
-
s = re.sub(r"`(.+?)`", r"\1", s)
|
| 96 |
-
s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", r"\1 (\2)", s)
|
| 97 |
-
s = re.sub(r"^_(.+)_$", r"\1", s.strip())
|
| 98 |
-
out.append(s)
|
| 99 |
-
i += 1
|
| 100 |
-
txt = "\n".join(out)
|
| 101 |
-
return re.sub(r"\n{3,}", "\n\n", txt).strip()
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
def _inline_md(s: str) -> str:
|
| 105 |
-
s = html.escape(s)
|
| 106 |
-
s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
|
| 107 |
-
s = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", s)
|
| 108 |
-
s = re.sub(r"`(.+?)`", r"<code>\1</code>", s)
|
| 109 |
-
s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)",
|
| 110 |
-
r'<a href="\2">\1</a>', s)
|
| 111 |
-
return s
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
def _md_to_html(text: str) -> str:
|
| 115 |
-
"""Minimal but correct Markdown → HTML (headings, tables, lists, bold,
|
| 116 |
-
code, links). The reports are Markdown, so emailing them as preformatted
|
| 117 |
-
text showed raw `#`/`|` symbols — this renders them properly."""
|
| 118 |
-
lines = text.split("\n")
|
| 119 |
-
out, i, n = [], 0, len(lines)
|
| 120 |
-
while i < n:
|
| 121 |
-
line = lines[i]
|
| 122 |
-
# table block: a header row of pipes followed by a |---| separator
|
| 123 |
-
if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", lines[i + 1]):
|
| 124 |
-
header = [c.strip() for c in line.strip().strip("|").split("|")]
|
| 125 |
-
i += 2
|
| 126 |
-
rows = []
|
| 127 |
-
while i < n and "|" in lines[i]:
|
| 128 |
-
rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
|
| 129 |
-
i += 1
|
| 130 |
-
th = "".join(f"<th style='text-align:left;padding:6px 10px;"
|
| 131 |
-
f"border-bottom:2px solid #ddd'>{_inline_md(c)}</th>" for c in header)
|
| 132 |
-
trs = ""
|
| 133 |
-
for r in rows:
|
| 134 |
-
tds = "".join(f"<td style='padding:6px 10px;border-bottom:1px solid #eee'>"
|
| 135 |
-
f"{_inline_md(c)}</td>" for c in r)
|
| 136 |
-
trs += f"<tr>{tds}</tr>"
|
| 137 |
-
out.append(f"<table style='border-collapse:collapse;margin:10px 0;"
|
| 138 |
-
f"font-size:13px'><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table>")
|
| 139 |
-
continue
|
| 140 |
-
m = re.match(r"^(#{1,4})\s+(.*)$", line)
|
| 141 |
-
if m:
|
| 142 |
-
lvl = len(m.group(1))
|
| 143 |
-
size = {1: 20, 2: 16, 3: 14, 4: 13}[lvl]
|
| 144 |
-
out.append(f"<h{lvl} style='font-size:{size}px;margin:14px 0 6px;"
|
| 145 |
-
f"color:#0265DC'>{_inline_md(m.group(2))}</h{lvl}>")
|
| 146 |
-
i += 1
|
| 147 |
-
continue
|
| 148 |
-
if re.match(r"^\s*[-*]\s+", line):
|
| 149 |
-
items = []
|
| 150 |
-
while i < n and re.match(r"^\s*[-*]\s+", lines[i]):
|
| 151 |
-
item_text = re.sub(r"^\s*[-*]\s+", "", lines[i])
|
| 152 |
-
items.append(f"<li>{_inline_md(item_text)}</li>")
|
| 153 |
-
i += 1
|
| 154 |
-
out.append(f"<ul style='margin:6px 0 6px 18px'>{''.join(items)}</ul>")
|
| 155 |
-
continue
|
| 156 |
-
if line.strip() in ("---", "***", "___"):
|
| 157 |
-
out.append("<hr style='border:none;border-top:1px solid #e3e6ea;margin:12px 0'>")
|
| 158 |
-
i += 1
|
| 159 |
-
continue
|
| 160 |
-
if line.strip() == "":
|
| 161 |
-
out.append("<div style='height:6px'></div>")
|
| 162 |
-
i += 1
|
| 163 |
-
continue
|
| 164 |
-
out.append(f"<p style='margin:4px 0'>{_inline_md(line)}</p>")
|
| 165 |
-
i += 1
|
| 166 |
-
body = "".join(out)
|
| 167 |
-
return (
|
| 168 |
-
'<html><body style="margin:0;padding:18px;background:#f6f7f9;">'
|
| 169 |
-
'<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;'
|
| 170 |
-
'font-size:14px;line-height:1.55;color:#1a1a1a;background:#ffffff;'
|
| 171 |
-
'padding:22px 26px;border-radius:12px;border:1px solid #e3e6ea;'
|
| 172 |
-
'max-width:780px;margin:0 auto;">'
|
| 173 |
-
f'{body}'
|
| 174 |
-
'<p style="font-size:11px;color:#8a8a8a;margin:16px 0 0;border-top:'
|
| 175 |
-
'1px solid #eee;padding-top:10px;">Sent by Chan Compass · educational '
|
| 176 |
-
'tool, not investment advice.</p>'
|
| 177 |
-
'</div></body></html>'
|
| 178 |
-
)
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
def _parse_recipients(raw: str) -> list:
|
| 182 |
-
parts = re.split(r"[,;\s]+", (raw or "").strip())
|
| 183 |
-
return [p for p in parts if _EMAIL_RE.match(p)]
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
def _close(server):
|
| 187 |
-
if server is not None:
|
| 188 |
try:
|
| 189 |
-
|
|
|
|
| 190 |
except Exception:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
try:
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
except Exception:
|
| 194 |
pass
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
"https://api.resend.com/emails", data=payload, method="POST",
|
| 208 |
-
headers={"Authorization": f"Bearer {RESEND_API_KEY}",
|
| 209 |
-
"Content-Type": "application/json",
|
| 210 |
-
# Resend/Cloudflare reject requests with no User-Agent
|
| 211 |
-
# (403, error code 1010) before they reach the API.
|
| 212 |
-
"User-Agent": "chan-compass/1.0 (+https://huggingface.co/spaces)"})
|
| 213 |
-
try:
|
| 214 |
-
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 215 |
-
body = resp.read().decode("utf-8", "ignore")
|
| 216 |
-
if '"id"' in body:
|
| 217 |
-
return ""
|
| 218 |
-
return f"Resend API responded without an id: {body[:200]}"
|
| 219 |
-
except urllib.error.HTTPError as e:
|
| 220 |
-
detail = e.read().decode("utf-8", "ignore")[:200]
|
| 221 |
-
return f"Resend HTTP {e.code}: {detail}"
|
| 222 |
-
except Exception as e:
|
| 223 |
-
return f"Resend request failed: {e}"
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
def _send_via_smtp(recipients: list, subject: str, content: str) -> str:
|
| 227 |
-
"""Classic SMTP — works locally / off-HF. Returns '' on success."""
|
| 228 |
-
if not EMAIL_SENDER or not EMAIL_PASSWORD:
|
| 229 |
-
return "SMTP sender not configured."
|
| 230 |
-
msg = MIMEMultipart("alternative")
|
| 231 |
-
msg["Subject"] = Header(subject, "utf-8")
|
| 232 |
-
msg["From"] = _sender_address(EMAIL_SENDER)
|
| 233 |
-
msg["To"] = ", ".join(recipients)
|
| 234 |
-
msg.attach(MIMEText(_md_to_plain(content), "plain", "utf-8"))
|
| 235 |
-
msg.attach(MIMEText(_md_to_html(content), "html", "utf-8"))
|
| 236 |
-
domain = EMAIL_SENDER.split("@")[-1].lower()
|
| 237 |
-
sc = SMTP_CONFIGS.get(domain, {"server": f"smtp.{domain}", "port": 465, "ssl": True})
|
| 238 |
-
server = None
|
| 239 |
-
try:
|
| 240 |
-
if sc["ssl"]:
|
| 241 |
-
server = smtplib.SMTP_SSL(sc["server"], sc["port"], timeout=20)
|
| 242 |
else:
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""缠论多级别联立分析
|
| 2 |
+
|
| 3 |
+
【本版改动 · 卖点区间套修复 (第24/44课)】
|
| 4 |
|
| 5 |
+
旧逻辑的两个病根(对应回测中"卖在低位"与"提前卖飞"):
|
| 6 |
+
① 卖在低位: 严格模式要求"30分钟出现三类卖点S3"才印证日线卖点。但S3是
|
| 7 |
+
次级别已经跌破其中枢、反抽不回的【转折确认点】—— 它出现时价格早已
|
| 8 |
+
离开顶部一大截。拿S3当卖出闸门, 等于规定"必须先跌下来才准卖", 结构上
|
| 9 |
+
注定卖不到一卖的高位, 最后落在二卖/三卖的低位。
|
| 10 |
+
② 提前卖飞: 宽松模式下"30m最后一笔向下"即印证。第24课明示: 小级别背驰
|
| 11 |
+
(更别说仅仅一笔向下)未必引发大级别转折。任何一次30m级别的正常回调都
|
| 12 |
+
可能把日线WEAK级别的卖点"确认"掉 → 还没到顶就出局。
|
| 13 |
+
而且旧代码对次级别卖点【不做时间嵌套检查】: 30m在日线C段开始之前的
|
| 14 |
+
旧背驰也会被当作印证 —— "区间套"三个字里的"区间"丢了。
|
| 15 |
|
| 16 |
+
新逻辑 (CFG['sell_nested_interval']=True):
|
| 17 |
+
第44课区间套的本义: 大级别进入背驰段后, 在该背驰段【时间区间内】找次级别
|
| 18 |
+
的背驰段, 再在其中找次次级别的背驰段, 逐级收敛到精确转折点。
|
| 19 |
+
对日线S1/S2, 次级别印证按优先级:
|
| 20 |
+
① 嵌套顶背驰: 30m出现S1, 且其C段顶部落在日线背驰段(最后中枢之后→当下)
|
| 21 |
+
的时间窗口内、顶部价位贴近日线C段高点 → 精确卖点, 卖在高位区。
|
| 22 |
+
② 次级别转折确认: 30m出现S3, 或30m末笔已跌破其最近中枢下沿ZD(第92课
|
| 23 |
+
向下变盘) → 转折已确认, 偏晚但必须卖。
|
| 24 |
+
③ 两者皆无 → 次级别动能未竭, 第24课: 顶未到 → 【不卖】(防卖飞),
|
| 25 |
+
返回 action=HOLD + sell_armed=True 进入"区间套布防"状态:
|
| 26 |
+
回测端持仓转入布防, 之后任一条件触发(嵌套背驰出现/破30m中枢ZD/
|
| 27 |
+
较布防峰值回落超阈值)即离场 —— 既不提前卖飞, 也不一路坐滑梯到三卖。
|
| 28 |
+
S3(日线三卖)不走嵌套背驰: 三卖本身就是转折确认型卖点, 维持原确认逻辑。
|
| 29 |
"""
|
| 30 |
from __future__ import annotations
|
| 31 |
+
from dataclasses import dataclass, field
|
| 32 |
+
from typing import Optional
|
| 33 |
+
import numpy as np
|
| 34 |
+
import pandas as pd
|
| 35 |
+
|
| 36 |
+
# from chan_engine import ChanAnalyzer, Signal
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ── 卖点区间套参数 (第24/44课) ──
|
| 40 |
+
NESTED_TOP_PRICE_TOL = 0.03 # 嵌套顶背驰的顶部须贴近父级C段高点(3%以内), 否则属上一波动能
|
| 41 |
+
NESTED_WINDOW_PAD_DAYS = 3 # 时间嵌套窗口的左侧容差(日)
|
| 42 |
+
SELL_ARM_PEAK_DROP = 0.04 # 布防后较峰值价回落≥4% → 顶部确认离场(回测端使用)
|
| 43 |
+
SELL_ARM_DISARM_BREAK = 0.03 # 布防后强势创新高超3%且卖点消失 → 背驰被消化, 撤防(第26课)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _default_make_analyzer(level, df):
|
| 47 |
+
return ChanAnalyzer(df.reset_index(drop=True))
|
| 48 |
+
|
| 49 |
+
_MAKE_ANALYZER = _default_make_analyzer
|
| 50 |
+
|
| 51 |
+
def set_analyzer_factory(fn):
|
| 52 |
+
global _MAKE_ANALYZER
|
| 53 |
+
_MAKE_ANALYZER = fn or _default_make_analyzer
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def resample_weekly(df_daily: pd.DataFrame) -> pd.DataFrame:
|
| 57 |
+
if df_daily.empty:
|
| 58 |
+
return df_daily.copy()
|
| 59 |
+
d = df_daily.copy()
|
| 60 |
+
d['date'] = pd.to_datetime(d['date'])
|
| 61 |
+
d = d.sort_values('date').reset_index(drop=True)
|
| 62 |
+
wk_period = d['date'].dt.to_period('W-FRI')
|
| 63 |
+
agg = {'open': 'first', 'high': 'max', 'low': 'min', 'close': 'last'}
|
| 64 |
+
if 'volume' in d.columns: agg['volume'] = 'sum'
|
| 65 |
+
if 'amount' in d.columns: agg['amount'] = 'sum'
|
| 66 |
+
g = d.groupby(wk_period, sort=True)
|
| 67 |
+
wk = g.agg(agg)
|
| 68 |
+
last_real = g['date'].max()
|
| 69 |
+
wk = wk.dropna(subset=['open', 'high', 'low', 'close']).reset_index(drop=True)
|
| 70 |
+
wk['date'] = list(last_real.values)
|
| 71 |
+
return wk
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def resample_monthly(df_daily: pd.DataFrame) -> pd.DataFrame:
|
| 75 |
+
if df_daily.empty:
|
| 76 |
+
return df_daily.copy()
|
| 77 |
+
d = df_daily.copy()
|
| 78 |
+
d['date'] = pd.to_datetime(d['date'])
|
| 79 |
+
d = d.sort_values('date').reset_index(drop=True)
|
| 80 |
+
mp = d['date'].dt.to_period('M')
|
| 81 |
+
agg = {'open': 'first', 'high': 'max', 'low': 'min', 'close': 'last'}
|
| 82 |
+
if 'volume' in d.columns: agg['volume'] = 'sum'
|
| 83 |
+
if 'amount' in d.columns: agg['amount'] = 'sum'
|
| 84 |
+
g = d.groupby(mp, sort=True)
|
| 85 |
+
mo = g.agg(agg)
|
| 86 |
+
last_real = g['date'].max()
|
| 87 |
+
mo = mo.dropna(subset=['open', 'high', 'low', 'close']).reset_index(drop=True)
|
| 88 |
+
mo['date'] = list(last_real.values)
|
| 89 |
+
return mo
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@dataclass
|
| 93 |
+
class LevelView:
|
| 94 |
+
level: str
|
| 95 |
+
trend: str
|
| 96 |
+
n_pivots: int
|
| 97 |
+
n_bis: int
|
| 98 |
+
last_bi_dir: str
|
| 99 |
+
signal: Optional[Signal]
|
| 100 |
+
zg: Optional[float]
|
| 101 |
+
zd: Optional[float]
|
| 102 |
+
dif: Optional[float]
|
| 103 |
+
last_date: Optional[pd.Timestamp]
|
| 104 |
+
diagnostics: dict = field(default_factory=dict)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@dataclass
|
| 108 |
+
class MultiLevelSignal:
|
| 109 |
+
code: str
|
| 110 |
+
analysis_date: pd.Timestamp
|
| 111 |
+
weekly: LevelView
|
| 112 |
+
daily: LevelView
|
| 113 |
+
m30: Optional[LevelView]
|
| 114 |
+
action: str
|
| 115 |
+
confidence: str
|
| 116 |
+
final_kind: str
|
| 117 |
+
cur_price: float
|
| 118 |
+
chain: list = field(default_factory=list)
|
| 119 |
+
blocked_reason: str = ''
|
| 120 |
+
note: str = ''
|
| 121 |
+
confidence_reasons: list = field(default_factory=list)
|
| 122 |
+
diagnostics: dict = field(default_factory=dict)
|
| 123 |
+
monthly: Optional[LevelView] = None
|
| 124 |
+
# ── 卖点区间套布防 (第44课) ──
|
| 125 |
+
sell_armed: bool = False # 日线S1/S2背驰已现、次级别动能未竭 → 持仓布防
|
| 126 |
+
arm_zd: Optional[float] = None # 布防线①: 30m最近中枢下沿ZD(跌破即次级别转折确认)
|
| 127 |
+
arm_high: Optional[float] = None # 父级背驰段C段高点(用于"新高消化背驰"撤防判定)
|
| 128 |
+
|
| 129 |
+
def explain(self) -> str:
|
| 130 |
+
lines = [f' [{self.code}] {self.analysis_date.strftime("%Y-%m-%d")} ¥{self.cur_price:.3f}']
|
| 131 |
+
lines.append(f' 最终: {self.action} 置信度={self.confidence}'
|
| 132 |
+
+ (f' 信号={self.final_kind}' if self.final_kind else ''))
|
| 133 |
+
lines.append(' ── 逐级裁决链 ──')
|
| 134 |
+
for lvl, concl, src in self.chain:
|
| 135 |
+
lines.append(f' [{lvl:<7s}] {concl}')
|
| 136 |
+
if src:
|
| 137 |
+
lines.append(f' └ 依据: {src}')
|
| 138 |
+
if self.blocked_reason:
|
| 139 |
+
lines.append(f' ⚠ 拦截: {self.blocked_reason}')
|
| 140 |
+
if self.note:
|
| 141 |
+
lines.append(f' 说明: {self.note}')
|
| 142 |
+
if self.confidence_reasons:
|
| 143 |
+
lines.append(' 置信度降档明细:')
|
| 144 |
+
for i, r in enumerate(self.confidence_reasons, 1):
|
| 145 |
+
lines.append(f' {i}. {r}')
|
| 146 |
+
if self.diagnostics:
|
| 147 |
+
lines.append(' 日线买卖点逐项诊断:')
|
| 148 |
+
for k in ('B1', 'B2', 'B3', 'S1', 'S2', 'S3'):
|
| 149 |
+
rows = self.diagnostics.get(k) or []
|
| 150 |
+
if not rows:
|
| 151 |
+
continue
|
| 152 |
+
ok = all(str(x).startswith('✓') for x in rows)
|
| 153 |
+
lines.append(f' {k} {"满足" if ok else "不满足"}')
|
| 154 |
+
for row in rows:
|
| 155 |
+
lines.append(f' {row}')
|
| 156 |
+
else:
|
| 157 |
+
lines.append(' 日线买卖点逐项诊断: 未生成')
|
| 158 |
+
return '\n'.join(lines)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
class MultiLevelChan:
|
| 162 |
+
CFG = {
|
| 163 |
+
'use_monthly_gate': True,
|
| 164 |
+
'monthly_ma_filter': False,
|
| 165 |
+
'require_sublevel_sell_confirm': False,
|
| 166 |
+
'mode': 'short',
|
| 167 |
+
'zhongyin_block_buy': False,
|
| 168 |
+
'expose_m30_nosignal': False,
|
| 169 |
+
'require_60m_buy_confirm': False, # L44区间套: 60m为日线直接次级别, 买点须经其印证
|
| 170 |
+
# L50 MACD级别选择: 看MACD要选"黄白线和柱子清晰"的级别。30m判据模糊而60m
|
| 171 |
+
# 清晰且60m已印证时, 采信60m的印证结论(避免被模糊级别的噪声误杀好买点)。
|
| 172 |
+
'l50_macd_level_select': False,
|
| 173 |
+
# ── 卖点区间套(第24/44课): 嵌套顶背驰精确定位 + 布防防卖飞 ──
|
| 174 |
+
# True = S1/S2用"时间嵌套的次级别顶背驰"定位高点; 未嵌套确认时不卖而布防
|
| 175 |
+
# False = 维持旧逻辑(30m要S3 / 宽松末笔向下即卖)
|
| 176 |
+
'sell_nested_interval': True,
|
| 177 |
+
# L24: 周线上涨+日线盘整背驰的S1/S2 → 不全清, 转布防短差(撤防可骑回趋势)
|
| 178 |
+
'l24_weekly_uptrend_arm': False, # 实测净亏(好卖点也被转布防), 默认关
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
# ── 速度优化: 次级别K线只取最近N根做缠论分解 ──
|
| 182 |
+
# 第17/44课: 次级别(60m/30m/15m/5m/1m)的职责是"印证当下转折", 其买卖点
|
| 183 |
+
# 只取决于最近的笔/线段/中枢结构; 几年前的分钟级历史对当下印证毫无贡献,
|
| 184 |
+
# 却让每个回测日都对上万根分钟K线重做合并/分型/笔/段, 是最大的耗时点。
|
| 185 |
+
# 截断只作用于次级别印证, 日/周/月线仍用全history(年线、月线大方向不受影响)。
|
| 186 |
+
SUB_TAIL = {'15m': 4000, '5m': 4800, '1m': 2000}
|
| 187 |
+
|
| 188 |
+
def __init__(self, df_daily, df_weekly=None, df_monthly=None, df_30m=None, df_60m=None,
|
| 189 |
+
df_15m=None, df_5m=None, df_1m=None, code='', strict=True):
|
| 190 |
+
self.code = code
|
| 191 |
+
self.strict = strict
|
| 192 |
+
self.df_daily = df_daily.reset_index(drop=True) if not df_daily.empty else df_daily
|
| 193 |
+
self.df_30m = df_30m.reset_index(drop=True) if df_30m is not None and not df_30m.empty else None
|
| 194 |
+
self.df_60m = df_60m.reset_index(drop=True) if df_60m is not None and not df_60m.empty else None
|
| 195 |
+
self.df_15m = df_15m.reset_index(drop=True) if df_15m is not None and not df_15m.empty else None
|
| 196 |
+
self.df_5m = df_5m.reset_index(drop=True) if df_5m is not None and not df_5m.empty else None
|
| 197 |
+
self.df_1m = df_1m.reset_index(drop=True) if df_1m is not None and not df_1m.empty else None
|
| 198 |
+
self.df_weekly = df_weekly.reset_index(drop=True) if df_weekly is not None and not df_weekly.empty else None
|
| 199 |
+
self.df_monthly = df_monthly.reset_index(drop=True) if df_monthly is not None and not df_monthly.empty else None
|
| 200 |
+
|
| 201 |
+
@staticmethod
|
| 202 |
+
def _make_view(level, df, an=None, diagnose=True):
|
| 203 |
+
if an is None:
|
| 204 |
+
if df is None or len(df) < 30:
|
| 205 |
+
return None
|
| 206 |
+
try:
|
| 207 |
+
an = _MAKE_ANALYZER(level, df)
|
| 208 |
+
except Exception:
|
| 209 |
+
return None
|
| 210 |
+
if an is None:
|
| 211 |
+
return None
|
| 212 |
+
sig = an.get_signal()
|
| 213 |
+
zg = an.pivots[-1].zg if an.n_pivots > 0 else None
|
| 214 |
+
zd = an.pivots[-1].zd if an.n_pivots > 0 else None
|
| 215 |
+
return LevelView(level=level, trend=an.trend, n_pivots=an.n_pivots, n_bis=an.n_bis,
|
| 216 |
+
last_bi_dir=an.bis[-1].direction if an.n_bis else '', signal=sig,
|
| 217 |
+
zg=zg, zd=zd, dif=float(an.dif.iloc[-1]) if len(an.dif) else None,
|
| 218 |
+
last_date=pd.Timestamp(df['date'].iloc[-1]),
|
| 219 |
+
diagnostics=(an.diagnose() if diagnose else {}))
|
| 220 |
+
|
| 221 |
+
# ──────────────────────────────────────────────────────────────────
|
| 222 |
+
# 卖点区间套核心: 时间嵌套的次级别顶背驰 (第44课)
|
| 223 |
+
# ──────────────────────────────────────────────────────────────────
|
| 224 |
+
@staticmethod
|
| 225 |
+
def _parent_sell_window(parent_sig):
|
| 226 |
+
"""父级(日线)背驰段的时间窗口与顶部价。
|
| 227 |
+
S1: 背驰段C段 = 最后中枢结束 → 当下; 顶 = C段高点 c_high。
|
| 228 |
+
S2: 窗口 = 一卖出现 → 当下(反抽段); 顶 = 一卖高点 prev_high。"""
|
| 229 |
+
ex = (parent_sig.extras or {}) if parent_sig is not None else {}
|
| 230 |
+
if parent_sig is None:
|
| 231 |
+
return '', '', None
|
| 232 |
+
if parent_sig.kind == 'S1':
|
| 233 |
+
w_start = ex.get('pivot_end_date') or ex.get('a_high_date') or ''
|
| 234 |
+
top = ex.get('c_high')
|
| 235 |
+
else: # S2
|
| 236 |
+
w_start = ex.get('s1_date') or ex.get('prev_high_date') or ''
|
| 237 |
+
top = ex.get('prev_high')
|
| 238 |
+
w_end = ex.get('price_date') or ''
|
| 239 |
+
return w_start, w_end, top
|
| 240 |
+
|
| 241 |
+
def _nested_sell_check(self, sub_an, parent_sig, lvl_name, parent_kind):
|
| 242 |
+
"""第44课区间套(卖点版): 在父级背驰段时间窗口内找次级别的嵌套顶背驰。
|
| 243 |
+
优先级: ①嵌套S1(精确高点, 卖在高位)
|
| 244 |
+
②S3 / 末笔破次级别中枢ZD(转折已确认, 偏晚但必卖)
|
| 245 |
+
③都没有 → 第24课: 次级别动能未竭, 顶未到 → 不卖(防卖飞), 转布防。
|
| 246 |
+
返回 (confirmed: bool, note: str)。"""
|
| 247 |
+
w_start, w_end, parent_top = self._parent_sell_window(parent_sig)
|
| 248 |
+
|
| 249 |
+
def in_window(dstr):
|
| 250 |
+
if not w_start or not dstr:
|
| 251 |
+
return True # 信息不足时不因窗口否决(保守放行, 由价位贴近度把关)
|
| 252 |
+
try:
|
| 253 |
+
d = pd.Timestamp(dstr)
|
| 254 |
+
lo = pd.Timestamp(w_start) - pd.Timedelta(days=NESTED_WINDOW_PAD_DAYS)
|
| 255 |
+
hi = (pd.Timestamp(w_end) if w_end else d) + pd.Timedelta(days=1)
|
| 256 |
+
return lo <= d <= hi
|
| 257 |
+
except Exception:
|
| 258 |
+
return True
|
| 259 |
+
|
| 260 |
+
rejected = ''
|
| 261 |
+
# ① 嵌套顶背驰 S1 —— 区间套的本体: 背驰段中套背驰段
|
| 262 |
+
s1 = sub_an.detect_s1()
|
| 263 |
+
if s1 is not None:
|
| 264 |
+
ex2 = s1.extras or {}
|
| 265 |
+
td = ex2.get('c_high_date', '')
|
| 266 |
+
tp = ex2.get('c_high')
|
| 267 |
+
near_top = (parent_top is None or tp is None
|
| 268 |
+
or tp >= parent_top * (1 - NESTED_TOP_PRICE_TOL))
|
| 269 |
+
if in_window(td) and near_top:
|
| 270 |
+
ptxt = f'(顶¥{tp:.3f}@{td})' if tp else ''
|
| 271 |
+
return True, (f'{lvl_name}嵌套顶背驰S1{ptxt}落在日线{parent_kind}背驰段窗口内 '
|
| 272 |
+
f'—— 第44课区间套: 背驰段中套背驰段, 精确定位顶部, 卖在高位区')
|
| 273 |
+
rejected = (f'{lvl_name}虽有S1但【不嵌套】(顶@{td or "?"}不在父级背驰段'
|
| 274 |
+
f'[{w_start}~{w_end}]窗口内, 或低于父级顶{NESTED_TOP_PRICE_TOL:.0%}以上)'
|
| 275 |
+
f' → 属上一波的动能衰竭, 不作本次印证(防卖飞); ')
|
| 276 |
+
# ①b 父级为S2时, 次级别S2(一卖后反抽确认)也可嵌套印证
|
| 277 |
+
if parent_kind == 'S2':
|
| 278 |
+
s2 = sub_an.detect_s2()
|
| 279 |
+
if s2 is not None:
|
| 280 |
+
ex2 = s2.extras or {}
|
| 281 |
+
td = ex2.get('cur_high_date', '')
|
| 282 |
+
if in_window(td):
|
| 283 |
+
return True, (f'{lvl_name}嵌套二卖S2(反抽顶@{td})落在日线S2窗口内 '
|
| 284 |
+
f'—— 第14课: 大级别二卖由次级别相应卖点定位')
|
| 285 |
+
# ② 转折已确认型: S3 / 末笔已破次级别中枢下沿
|
| 286 |
+
s3 = sub_an.detect_s3()
|
| 287 |
+
if s3 is not None:
|
| 288 |
+
return True, (f'{lvl_name}出现三类卖点S3 —— 第44课: 最后一个次级别中枢出现三卖, '
|
| 289 |
+
f'转折已确认(偏晚, 但必须卖)')
|
| 290 |
+
try:
|
| 291 |
+
osc = sub_an.zhongshu_oscillation_monitor()
|
| 292 |
+
if osc.get('alert') and osc.get('direction') == 'down':
|
| 293 |
+
return True, (f'{lvl_name}末笔已离开其最近中枢下沿(第92课向下变盘) '
|
| 294 |
+
f'—— 次级别转折确认, 高位区兑现')
|
| 295 |
+
except Exception:
|
| 296 |
+
pass
|
| 297 |
+
# ③ 次级别上冲动能已竭的当下确认: 末笔向下 + MACD柱已翻负(黄白线收敛下行)。
|
| 298 |
+
# 第24/44课: 区间套要的是"次级别走势的当下转折"; 末笔转下且红柱消失,
|
| 299 |
+
# 说明次级别这一冲已经结束 —— 此时日线背驰卖点立即执行, 不再等更深的破位
|
| 300 |
+
# (这正是旧版"等30m三卖"卖在低位的病根)。只有次级别仍在上冲(末笔向上/
|
| 301 |
+
# 红柱未消)时才转入布防, 防的才是真正的"卖飞"。
|
| 302 |
+
try:
|
| 303 |
+
last_bi = sub_an.bis[-1] if sub_an.n_bis else None
|
| 304 |
+
bar_now = float(sub_an.macd_bar.iloc[-1]) if len(sub_an.macd_bar) else 0.0
|
| 305 |
+
if last_bi is not None and last_bi.direction == 'down' and bar_now < 0:
|
| 306 |
+
return True, (f'{lvl_name}末笔向下且MACD柱已翻负 —— 第24课: 次级别上冲动能已竭, '
|
| 307 |
+
f'当下转折确认, 日线背驰卖点立即执行(不等{lvl_name}跌出三卖)')
|
| 308 |
+
except Exception:
|
| 309 |
+
pass
|
| 310 |
+
return False, (rejected + f'{lvl_name}动能未竭(末笔仍向上/MACD红柱未消): 无嵌套顶背驰/无S3 '
|
| 311 |
+
f'→ 第24课: 次级别未转折, 大级别顶未到 → 暂不卖(防卖飞), 转入区间套布防')
|
| 312 |
+
|
| 313 |
+
def _confirm_30m_for_buy(self, m30, parent_kind=''):
|
| 314 |
+
if m30 is None or m30.n_bis < 5:
|
| 315 |
+
return False, '30分钟笔数不足(<5), 无法做次级别精确印证'
|
| 316 |
+
if parent_kind == 'B2':
|
| 317 |
+
if m30.detect_b1() is not None:
|
| 318 |
+
return True, '30分钟出现一类买点B1 —— 第14课: 大级别第二类买点由次一级别相应走势的一类买点构成'
|
| 319 |
+
if m30.detect_b2() is not None:
|
| 320 |
+
return True, '30分钟出现二类买点B2 —— 次级别一买后的回试确认, 属延后确认, 精度低于B1'
|
| 321 |
+
return False, '30分钟未出现B1/B2 —— 大级别二买缺少次级别买点确认'
|
| 322 |
+
for fn, name in ((m30.detect_b1,'B1'),(m30.detect_b3,'B3'),(m30.detect_b2,'B2')):
|
| 323 |
+
if fn() is not None:
|
| 324 |
+
return True, f'30分钟出现标准买点{name} —— 第44课: 次级别走势确认转折, 日线买点成立'
|
| 325 |
+
if self.strict:
|
| 326 |
+
return False, '30分钟未出现任何标准买点(B1/B2/B3) —— 严格模式: 第44课次级别印证未满足, 日线买点暂不成立'
|
| 327 |
+
if m30.bis[-1].direction == 'up':
|
| 328 |
+
return True, '30分钟最后一笔向上, 次级别已启动(宽松确认)'
|
| 329 |
+
return False, '30分钟最后一笔仍向下且无买点, 次级别未确认转折'
|
| 330 |
+
|
| 331 |
+
def _confirm_30m_for_sell(self, m30, parent_kind='', parent_sig=None):
|
| 332 |
+
if m30 is None or m30.n_bis < 5:
|
| 333 |
+
return False, '30分钟笔数不足(<5), 无法做次级别精确印证'
|
| 334 |
+
# ── 新: 卖点区间套(第24/44课) —— S1/S2用嵌套顶背驰定位高点 ──
|
| 335 |
+
if (self.CFG.get('sell_nested_interval') and parent_sig is not None
|
| 336 |
+
and parent_kind in ('S1', 'S2')):
|
| 337 |
+
return self._nested_sell_check(m30, parent_sig, '30分钟', parent_kind)
|
| 338 |
+
# ── 旧逻辑(S3父级 / 开关关闭时) ──
|
| 339 |
+
if parent_kind == 'S2':
|
| 340 |
+
if m30.detect_s1() is not None:
|
| 341 |
+
return True, '30分钟出现一类卖点S1 —— 大级别第二类卖点由次一级别相应走势的一类卖点精确定位'
|
| 342 |
+
if m30.detect_s2() is not None:
|
| 343 |
+
return True, '30分钟出现二类卖点S2 —— 次级别一卖后的反抽确认, 属延后确认, 精度低于S1'
|
| 344 |
+
return False, '30分钟未出现S1/S2 —— 大级别二卖缺少次级别卖点确认'
|
| 345 |
+
if m30.detect_s3() is not None:
|
| 346 |
+
return True, '30分钟出现三类卖点S3 —— 严格满足第44课"小背驰-大转折定理"的必要条件(最后一个次级别中枢出现三卖)'
|
| 347 |
+
if self.strict:
|
| 348 |
+
return False, '30分钟未出现三类卖点S3 —— 严格模式: 第44课明确要求"最后一个次级别中枢出现三卖", 必要条件未满足, 日线卖点不构成大级别转��'
|
| 349 |
+
if m30.detect_s1() is not None:
|
| 350 |
+
return True, '30分钟出现一类卖点(顶背驰), 次级别转折确认(宽松)'
|
| 351 |
+
if m30.bis[-1].direction == 'down':
|
| 352 |
+
return True, '30分钟最后一笔向下, 次级别已转弱(宽松确认)'
|
| 353 |
+
return False, '30分钟未出现卖点且最后一笔仍向上, 次级别未确认转折'
|
| 354 |
+
|
| 355 |
+
def _confirm_finer(self, an, is_buy, lvl_name, parent_name, parent_kind='', parent_sig=None):
|
| 356 |
+
if an is None or an.n_bis < 5:
|
| 357 |
+
return False, f'{lvl_name}笔数不足(<5), 无法做次级别精确印证'
|
| 358 |
+
if is_buy:
|
| 359 |
+
if parent_kind == 'B2':
|
| 360 |
+
if an.detect_b1() is not None:
|
| 361 |
+
return True, f'{lvl_name}出现B1 —— 第14课: {parent_name}二买由次一级别一买构成'
|
| 362 |
+
if an.detect_b2() is not None:
|
| 363 |
+
return True, f'{lvl_name}出现B2 —— 次级别一买后的回试确认, 属延后确认, 精度低于B1'
|
| 364 |
+
return False, f'{lvl_name}未出现B1/B2 —— {parent_name}二买缺少次级别买点确认'
|
| 365 |
+
for fn, name in ((an.detect_b1,'B1'),(an.detect_b3,'B3'),(an.detect_b2,'B2')):
|
| 366 |
+
if fn() is not None:
|
| 367 |
+
return True, f'{lvl_name}出现标准买点{name} —— 第44课逐级处理: {lvl_name}走势确认{parent_name}的转折'
|
| 368 |
+
if self.strict:
|
| 369 |
+
return False, f'{lvl_name}未出现标准买点(B1/B2/B3) —— 严格模式: 末级印证未满足, 信号降级'
|
| 370 |
+
if an.bis[-1].direction == 'up':
|
| 371 |
+
return True, f'{lvl_name}最后一笔向上, 次级别已启动(宽松确认)'
|
| 372 |
+
return False, f'{lvl_name}最后一笔仍向下且无买点, 末级未确认, 信号降级'
|
| 373 |
+
else:
|
| 374 |
+
# ── 新: 卖点区间套向更细级别逐级传递(同一父级背驰段窗口) ──
|
| 375 |
+
if (self.CFG.get('sell_nested_interval') and parent_sig is not None
|
| 376 |
+
and parent_kind in ('S1', 'S2')):
|
| 377 |
+
ok, note = self._nested_sell_check(an, parent_sig, lvl_name, parent_kind)
|
| 378 |
+
if ok:
|
| 379 |
+
return True, note + f' —— 第44课逐级处理: {lvl_name}确认{parent_name}的转折'
|
| 380 |
+
return False, note
|
| 381 |
+
if parent_kind == 'S2':
|
| 382 |
+
if an.detect_s1() is not None:
|
| 383 |
+
return True, f'{lvl_name}出现S1 —— {parent_name}二卖由次一级别一卖精确定位'
|
| 384 |
+
if an.detect_s2() is not None:
|
| 385 |
+
return True, f'{lvl_name}出现S2 —— 次级别一卖后的反抽确认, 属延后确认, 精度低于S1'
|
| 386 |
+
return False, f'{lvl_name}未出现S1/S2 —— {parent_name}二卖缺少次级别卖点确认'
|
| 387 |
+
if an.detect_s3() is not None:
|
| 388 |
+
return True, f'{lvl_name}出现三类卖点S3 —— 第44课逐级处理: {lvl_name}走势确认{parent_name}的转折'
|
| 389 |
+
if self.strict:
|
| 390 |
+
return False, f'{lvl_name}未出现三类卖点S3 —— 严格模式: 末级印证未满足, 信号降级'
|
| 391 |
+
if an.detect_s1() is not None:
|
| 392 |
+
return True, f'{lvl_name}出现一类卖点(顶背驰), 次级别转折确认(宽松)'
|
| 393 |
+
if an.bis[-1].direction == 'down':
|
| 394 |
+
return True, f'{lvl_name}最后一笔向下, 次级别已转弱(宽松确认)'
|
| 395 |
+
return False, f'{lvl_name}未出现卖点且最后一笔仍向上, 末级未确认, 信号降级'
|
| 396 |
+
|
| 397 |
+
def _confirm_5m(self, m5, is_buy, parent_kind='', parent_sig=None):
|
| 398 |
+
return self._confirm_finer(m5, is_buy, '5分钟', '30分钟', parent_kind, parent_sig)
|
| 399 |
+
|
| 400 |
+
def _confirm_1m(self, m1, is_buy, parent_kind='', parent_sig=None):
|
| 401 |
+
return self._confirm_finer(m1, is_buy, '1分钟', '5分钟', parent_kind, parent_sig)
|
| 402 |
+
|
| 403 |
+
def analyze(self, analysis_date=None, positions=None):
|
| 404 |
+
if self.df_daily.empty or len(self.df_daily) < 30:
|
| 405 |
+
return None
|
| 406 |
+
df_d = self.df_daily
|
| 407 |
+
if analysis_date is not None:
|
| 408 |
+
analysis_date = pd.Timestamp(analysis_date)
|
| 409 |
+
df_d = df_d[df_d['date'] <= analysis_date].reset_index(drop=True)
|
| 410 |
+
if len(df_d) < 30:
|
| 411 |
+
return None
|
| 412 |
+
last_date = pd.Timestamp(df_d['date'].iloc[-1])
|
| 413 |
+
cur_price = float(df_d['close'].iloc[-1])
|
| 414 |
+
|
| 415 |
+
if self.df_weekly is not None:
|
| 416 |
+
df_w = self.df_weekly[self.df_weekly['date'] <= last_date].reset_index(drop=True)
|
| 417 |
+
else:
|
| 418 |
+
df_w = resample_weekly(df_d)
|
| 419 |
+
if self.df_monthly is not None:
|
| 420 |
+
df_mo = self.df_monthly[self.df_monthly['date'] <= last_date].reset_index(drop=True)
|
| 421 |
+
else:
|
| 422 |
+
df_mo = resample_monthly(df_d)
|
| 423 |
+
_tail = self.SUB_TAIL or {}
|
| 424 |
+
def _cut_sub(df, lvl):
|
| 425 |
+
if df is None:
|
| 426 |
+
return None
|
| 427 |
+
sub = df[df['date'] <= last_date + pd.Timedelta(days=1)]
|
| 428 |
+
n = _tail.get(lvl, 0)
|
| 429 |
+
if n and len(sub) > n:
|
| 430 |
+
sub = sub.tail(n)
|
| 431 |
+
return sub.reset_index(drop=True)
|
| 432 |
+
df_6 = _cut_sub(self.df_60m, '60m')
|
| 433 |
+
df_m = _cut_sub(self.df_30m, '30m')
|
| 434 |
+
df_15 = _cut_sub(self.df_15m, '15m')
|
| 435 |
+
df_5 = _cut_sub(self.df_5m, '5m')
|
| 436 |
+
df_1 = _cut_sub(self.df_1m, '1m')
|
| 437 |
|
| 438 |
+
wv = self._make_view('weekly', df_w, diagnose=False)
|
| 439 |
+
mov = self._make_view('monthly', df_mo, diagnose=False) if self.CFG.get('use_monthly_gate') else None
|
| 440 |
+
daily_an = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
try:
|
| 442 |
+
if len(df_d) >= 30:
|
| 443 |
+
daily_an = _MAKE_ANALYZER('daily', df_d)
|
| 444 |
except Exception:
|
| 445 |
+
daily_an = None
|
| 446 |
+
dv = self._make_view('daily', df_d, an=daily_an, diagnose=False) if daily_an is not None \
|
| 447 |
+
else self._make_view('daily', df_d, diagnose=False)
|
| 448 |
+
|
| 449 |
+
m60_an = None
|
| 450 |
+
m60_built = False
|
| 451 |
+
def _get_m60():
|
| 452 |
+
nonlocal m60_an, m60_built
|
| 453 |
+
if not m60_built:
|
| 454 |
+
m60_built = True
|
| 455 |
+
if df_6 is not None and len(df_6) >= 30:
|
| 456 |
+
try: m60_an = _MAKE_ANALYZER('60m', df_6)
|
| 457 |
+
except Exception: m60_an = None
|
| 458 |
+
return m60_an
|
| 459 |
+
|
| 460 |
+
m30_an = None
|
| 461 |
+
m30_built = False
|
| 462 |
+
def _get_m30():
|
| 463 |
+
nonlocal m30_an, m30_built
|
| 464 |
+
if not m30_built:
|
| 465 |
+
m30_built = True
|
| 466 |
+
if df_m is not None and len(df_m) >= 30:
|
| 467 |
+
try: m30_an = _MAKE_ANALYZER('30m', df_m)
|
| 468 |
+
except Exception: m30_an = None
|
| 469 |
+
return m30_an
|
| 470 |
+
|
| 471 |
+
def _mv():
|
| 472 |
+
an = _get_m30()
|
| 473 |
+
if an is None:
|
| 474 |
+
return None
|
| 475 |
+
return self._make_view('30m', df_m, an=an, diagnose=False)
|
| 476 |
+
|
| 477 |
+
diagnostics = daily_an.diagnose() if daily_an is not None else {}
|
| 478 |
+
|
| 479 |
+
chain = []
|
| 480 |
+
yearline_dir = 'unknown'; yearline_val = None
|
| 481 |
+
if len(df_d) >= 60:
|
| 482 |
+
_n = min(250, len(df_d))
|
| 483 |
+
yearline_val = float(df_d['close'].tail(_n).mean()) if _n >= 20 else None
|
| 484 |
+
_ma = df_d['close'].rolling(min(250, len(df_d)), min_periods=20).mean()
|
| 485 |
+
if len(_ma) and not pd.isna(_ma.iloc[-1]):
|
| 486 |
+
yearline_val = float(_ma.iloc[-1])
|
| 487 |
+
if yearline_val is not None:
|
| 488 |
+
above = cur_price >= yearline_val
|
| 489 |
+
yearline_dir = 'above' if above else 'below'
|
| 490 |
+
ytxt = (f'现价¥{cur_price:.3f} {"≥" if above else "<"} 年线MA250 ¥{yearline_val:.3f} '
|
| 491 |
+
f'→ {"站上年线, 长线可做多" if above else "年线下方, 第106课不做多(只看反弹/卖点)"}')
|
| 492 |
+
chain.append(('年线', ytxt,
|
| 493 |
+
'第7/106课: 年线(MA250)是长线生命线; 站上才考虑做多, 跌破年线长线转空'))
|
| 494 |
+
else:
|
| 495 |
+
chain.append(('年线', '日线历史不足, 年线MA250 暂不可用', '第7/106课'))
|
| 496 |
+
|
| 497 |
+
monthly_dir = 'unknown'
|
| 498 |
+
if mov is not None:
|
| 499 |
+
monthly_dir = mov.trend
|
| 500 |
+
chain.append(('monthly', f'{monthly_dir} (月线定大方向: L69 月线看最实质大方向)',
|
| 501 |
+
'第69/108课: 月线分型/笔/线段确定中期大方向; 底部=第一类买点到中枢首次走出三买前'))
|
| 502 |
+
if self.CFG.get('monthly_ma_filter') and len(df_mo) >= 5:
|
| 503 |
+
ma5_m = float(df_mo['close'].tail(5).mean())
|
| 504 |
+
if cur_price < ma5_m and monthly_dir != 'down_trend':
|
| 505 |
+
monthly_dir = 'down_trend'
|
| 506 |
+
chain.append(('monthly', f'价({cur_price:.2f})在5月线({ma5_m:.2f})下 → 大方向按偏空处理',
|
| 507 |
+
'第106课: 5月线是长线的关键, 牛市第一轮调整不跌破5月线'))
|
| 508 |
+
|
| 509 |
+
if wv is None:
|
| 510 |
+
chain.append(('weekly', '周线数据不足(<30根), 方向未知', ''))
|
| 511 |
+
weekly_dir = 'unknown'
|
| 512 |
+
else:
|
| 513 |
+
weekly_dir = wv.trend
|
| 514 |
+
dir_txt = {'up_trend':'上涨趋势 → 日线只接受【买点】','down_trend':'下跌趋势 → 日线只接受【卖点】/观望',
|
| 515 |
+
'consolidation':'盘整 → 日线买卖点都看,但降级'}.get(weekly_dir, weekly_dir)
|
| 516 |
+
chain.append(('weekly', f'{weekly_dir} {dir_txt}',
|
| 517 |
+
'第43课: 大级别走势类型限定小级别的操作方向; 不允许"上涨+上涨""下跌+下跌"'))
|
| 518 |
+
|
| 519 |
+
if dv is None:
|
| 520 |
+
return None
|
| 521 |
+
daily_sig = dv.signal
|
| 522 |
+
if daily_sig is None:
|
| 523 |
+
chain.append(('daily', f'{dv.trend}, 无买卖点信号', ''))
|
| 524 |
+
else:
|
| 525 |
+
chain.append(('daily', f'出现 {daily_sig.kind}: {daily_sig.reason[:400]}', '第21课: 买卖点完备性定理'))
|
| 526 |
+
|
| 527 |
+
if daily_sig is None:
|
| 528 |
+
action, conf, note = self._no_signal_decision(wv, dv, cur_price)
|
| 529 |
+
m30_ns = _mv() if self.CFG.get('expose_m30_nosignal') else None
|
| 530 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=m30_ns,
|
| 531 |
+
action=action, confidence=conf, final_kind='', cur_price=cur_price, chain=chain, note=note,
|
| 532 |
+
diagnostics=diagnostics, monthly=mov)
|
| 533 |
+
|
| 534 |
+
is_buy = daily_sig.kind in ('B1','B2','B3')
|
| 535 |
+
is_sell = daily_sig.kind in ('S1','S2','S3')
|
| 536 |
+
|
| 537 |
+
if self.CFG.get('zhongyin_block_buy') and is_buy and daily_sig.kind != 'B3' and daily_an is not None:
|
| 538 |
+
zy = daily_an.in_zhongyin()
|
| 539 |
+
if zy.get('in_zhongyin'):
|
| 540 |
+
chain.append(('中阴', f'第88-90课: 当前处中阴(方向未定+BOLL收口) → 暂不开新仓: {zy["reason"][:120]}', '第89课: 中阴阶段方向未定, 不宜开新仓'))
|
| 541 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=None,
|
| 542 |
+
action='WATCH', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 543 |
+
chain=chain, blocked_reason='中阴方向未定', note='中阴阶段暂不开仓(L88-90)',
|
| 544 |
+
diagnostics=diagnostics, monthly=mov)
|
| 545 |
+
|
| 546 |
+
if positions:
|
| 547 |
+
fail_kinds = []
|
| 548 |
+
for bk, pos in positions.items():
|
| 549 |
+
stop = pos.get('stop_px')
|
| 550 |
+
if stop is not None and cur_price < stop:
|
| 551 |
+
fail_kinds.append(bk)
|
| 552 |
+
if fail_kinds:
|
| 553 |
+
chain.append(('证伪', f'持仓{"/".join(fail_kinds)}跌破建仓日锁定止损线 → 清仓', '第13/20课: 买点结构被破坏即证伪'))
|
| 554 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 555 |
+
action='SELL', confidence='HIGH', final_kind='STOP', cur_price=cur_price,
|
| 556 |
+
chain=chain, note='结构证伪止损, 次日清仓',
|
| 557 |
+
diagnostics=diagnostics, monthly=mov)
|
| 558 |
+
|
| 559 |
+
blocked = ''
|
| 560 |
+
downgrade = False
|
| 561 |
+
if weekly_dir == 'up_trend' and is_sell:
|
| 562 |
+
downgrade = True
|
| 563 |
+
chain.append(('联立','周线上涨 + 日线卖点 → 第44课: 小级别(日线)顶背驰未必引发大级别(周线)转折, 卖点降级为"减仓/短差"',
|
| 564 |
+
'第24课: 日线背驰除非周线同时背驰,否则不制造周线大顶'))
|
| 565 |
+
elif weekly_dir == 'down_trend' and is_buy:
|
| 566 |
+
if daily_sig.kind in ('B1','B2'):
|
| 567 |
+
chain.append(('联立','周线下跌 + 日线一/二买 → 下跌趋势的转折尝试, 必须由30分钟严格印证','第29课: 下跌的转折=上涨或盘整, 由背驰导致'))
|
| 568 |
+
else:
|
| 569 |
+
blocked = '周线下跌��势中出现日线三买 —— 第43课: 三买属上涨结构, 与周线下跌矛盾, 大概率是下跌中继的假三买'
|
| 570 |
+
elif monthly_dir == 'down_trend' and is_buy and daily_sig.kind == 'B3' and not blocked:
|
| 571 |
+
blocked = '月线下跌大方向中出现日线三买 —— 第43/69课: 三买属上涨结构, 与月线大方向矛盾, 大概率假三买'
|
| 572 |
+
elif weekly_dir == 'up_trend' and is_buy:
|
| 573 |
+
chain.append(('联立','周线上涨 + 日线买点 → 方向一致, 进入30分钟精确定位','第43课: 大小级别同向, 操作最顺'))
|
| 574 |
+
elif weekly_dir == 'down_trend' and is_sell:
|
| 575 |
+
chain.append(('联立','周线下跌 + 日线卖点 → 方向一致, 趋势性卖出','第43课: 大小级别同向'))
|
| 576 |
+
elif weekly_dir == 'consolidation':
|
| 577 |
+
downgrade = True
|
| 578 |
+
chain.append(('联立','周线盘整 → 日线信号有效但降级(盘整中买卖点力度弱)','第29课: 盘整中的转折力度弱于趋势'))
|
| 579 |
+
|
| 580 |
+
# ── 周线二买 → 日线一买精确定位锚 (第14/17课) ──
|
| 581 |
+
if is_buy and wv is not None and wv.signal is not None \
|
| 582 |
+
and getattr(wv.signal, 'kind', '') == 'B2':
|
| 583 |
+
_dex = (daily_sig.extras or {}) if daily_sig is not None else {}
|
| 584 |
+
_anchor = _dex.get('b1_price') or _dex.get('cur_low')
|
| 585 |
+
if _anchor:
|
| 586 |
+
daily_sig.extras = dict(_dex)
|
| 587 |
+
daily_sig.extras['weekly_b2_daily_b1_anchor'] = float(_anchor)
|
| 588 |
+
chain.append(('联立',
|
| 589 |
+
f'✓ 周线二买 + 日线买点共振: 周线B2由日线一买构成(第14课), '
|
| 590 |
+
f'定位锚=日线一买位¥{float(_anchor):.3f}, 跌破该锚则周线二买证伪',
|
| 591 |
+
'第17课: 大级别买点的精确定位要落到次级别的具体买点价位'))
|
| 592 |
+
else:
|
| 593 |
+
chain.append(('联立',
|
| 594 |
+
'周线二买成立但日线尚无可定位的一买锚 → 等日线给出一买价位再重仓',
|
| 595 |
+
'第14课'))
|
| 596 |
+
|
| 597 |
+
if blocked:
|
| 598 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 599 |
+
action='WATCH', confidence='NONE', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 600 |
+
chain=chain, blocked_reason=blocked, note='周线方向闸门拦截, 不操作',
|
| 601 |
+
diagnostics=diagnostics, monthly=mov)
|
| 602 |
+
|
| 603 |
+
m60_an = _get_m60()
|
| 604 |
+
ok60 = False
|
| 605 |
+
if m60_an is not None:
|
| 606 |
+
if is_buy:
|
| 607 |
+
ok60, note60 = self._confirm_finer(m60_an, True, '60分钟', '日线', daily_sig.kind)
|
| 608 |
+
else:
|
| 609 |
+
ok60, note60 = self._confirm_finer(m60_an, False, '60分钟', '日线', daily_sig.kind,
|
| 610 |
+
parent_sig=daily_sig)
|
| 611 |
+
chain.append(('60m', ('✓ 次级别确认: ' if ok60 else '✗ 次级别未确认(降级): ')+note60,
|
| 612 |
+
'第44课: 区间套 —— 日线的转折先由直接次级别60分钟走势确认'))
|
| 613 |
+
if not ok60:
|
| 614 |
+
downgrade = True
|
| 615 |
+
if self.CFG.get('require_60m_buy_confirm') and is_buy and daily_sig.kind in ('B1', 'B2'):
|
| 616 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 617 |
+
action='WATCH', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 618 |
+
chain=chain, blocked_reason='60分钟直接次级别未印证买点(L44区间套)',
|
| 619 |
+
note=f'日线{daily_sig.kind}买点但60m未现B1/B2 → 等直接次级别印证再动手',
|
| 620 |
+
diagnostics=diagnostics, monthly=mov)
|
| 621 |
+
else:
|
| 622 |
+
if df_6 is not None:
|
| 623 |
+
chain.append(('60m', '60分钟数据不足(<30根) → 跳过该级印证', '第44课: 区间套逐级确认'))
|
| 624 |
+
|
| 625 |
+
m30_an = _get_m30()
|
| 626 |
+
if m30_an is None:
|
| 627 |
+
chain.append(('30m','无30分钟数据 → 缺少次级别精确印证, 信号降级','第44课: 操作级别的买卖点需次级别走势确认'))
|
| 628 |
+
confirmed = False; confirm_note = '缺30分钟数据'; downgrade = True
|
| 629 |
+
else:
|
| 630 |
+
if is_buy:
|
| 631 |
+
confirmed, confirm_note = self._confirm_30m_for_buy(m30_an, daily_sig.kind)
|
| 632 |
+
else:
|
| 633 |
+
confirmed, confirm_note = self._confirm_30m_for_sell(m30_an, daily_sig.kind,
|
| 634 |
+
parent_sig=daily_sig)
|
| 635 |
+
chain.append(('30m', ('✓ 次级别确认: ' if confirmed else '✗ 次级别未确认: ')+confirm_note, '第44课: 小背驰-大转折定理(必要条件)'))
|
| 636 |
+
|
| 637 |
+
# ── 60m嵌套印证补位 (第44课区间套, 卖点版) ──
|
| 638 |
+
# 30m动能未竭但60m(日线的直接���级别)已给出嵌套顶背驰/转折确认 → 采信60m。
|
| 639 |
+
# 日线的"次级别"本就是60m; 30m是60m的次级别, 不应让更细级别一票否决直接次级别。
|
| 640 |
+
if (self.CFG.get('sell_nested_interval') and is_sell and not confirmed
|
| 641 |
+
and ok60 and daily_sig.kind in ('S1', 'S2')):
|
| 642 |
+
confirmed = True
|
| 643 |
+
confirm_note = '30m动能未竭, 但60m(日线直接次级别)已嵌套印证 → 采信60m(第44课: 逐级处理, 直接次级别优先)'
|
| 644 |
+
chain.append(('联立', '✓ ' + confirm_note, '第44课区间套'))
|
| 645 |
+
|
| 646 |
+
# ── L50 MACD级别选择: 选黄白线和柱子清晰的级别看MACD ──
|
| 647 |
+
if (self.CFG.get('l50_macd_level_select') and is_buy and not confirmed
|
| 648 |
+
and ok60 and m60_an is not None and m30_an is not None):
|
| 649 |
try:
|
| 650 |
+
c60 = m60_an.macd_clarity(); c30 = m30_an.macd_clarity()
|
| 651 |
+
if c30['score'] < 0.4 and c60['score'] >= max(0.4, c30['score'] * 1.5):
|
| 652 |
+
confirmed = True
|
| 653 |
+
chain.append(('联立',
|
| 654 |
+
f"✓ L50 MACD级别选择: 30m MACD{c30['label']}(清晰度{c30['score']}) 判据不可靠; "
|
| 655 |
+
f"60m MACD{c60['label']}(清晰度{c60['score']})且60m已印证买点 → 采信60m结论",
|
| 656 |
+
'第50课: 看MACD要选黄白线和柱子走势清晰的级别'))
|
| 657 |
except Exception:
|
| 658 |
pass
|
| 659 |
|
| 660 |
+
m15_an = None; m15_confirmed = None
|
| 661 |
+
if confirmed and df_15 is not None and len(df_15) >= 30:
|
| 662 |
+
try: m15_an = _MAKE_ANALYZER('15m', df_15)
|
| 663 |
+
except Exception: m15_an = None
|
| 664 |
+
if not confirmed:
|
| 665 |
+
pass
|
| 666 |
+
elif df_15 is None:
|
| 667 |
+
pass
|
| 668 |
+
elif m15_an is None:
|
| 669 |
+
chain.append(('15m','15分钟数据不足(<30根) → 跳过该级印证','第44课: 区间套逐级确认'))
|
| 670 |
+
else:
|
| 671 |
+
ok15, note15 = self._confirm_finer(m15_an, is_buy, '15分钟', '30分钟', daily_sig.kind,
|
| 672 |
+
parent_sig=(daily_sig if is_sell else None))
|
| 673 |
+
m15_confirmed = ok15
|
| 674 |
+
chain.append(('15m', ('✓ 次级别确认: ' if ok15 else '✗ 次级别未确认(降级,不拦截): ')+note15,
|
| 675 |
+
'第44课: 逐级处理 —— 30分钟的转折由15分钟走势确认'))
|
| 676 |
+
if not ok15: downgrade = True
|
| 677 |
|
| 678 |
+
m5_an = None; m5_confirmed = None
|
| 679 |
+
if confirmed and df_5 is not None and len(df_5) >= 30:
|
| 680 |
+
try: m5_an = _MAKE_ANALYZER('5m', df_5)
|
| 681 |
+
except Exception: m5_an = None
|
| 682 |
+
if not confirmed:
|
| 683 |
+
pass
|
| 684 |
+
elif df_5 is None:
|
| 685 |
+
chain.append(('5m','无5分钟数据 → 缺该级逐级印证, 信号降级','第44课: 15分钟的转折需5分钟走势逐级确认')); downgrade = True
|
| 686 |
+
elif m5_an is None:
|
| 687 |
+
chain.append(('5m','5分钟数据不足(<30根) → 缺该级印证, 信号降级','第44课: 15分钟的转折需5分钟走势逐级确认')); downgrade = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
else:
|
| 689 |
+
ok5, note5 = self._confirm_5m(m5_an, is_buy, daily_sig.kind,
|
| 690 |
+
parent_sig=(daily_sig if is_sell else None))
|
| 691 |
+
m5_confirmed = ok5
|
| 692 |
+
chain.append(('5m', ('✓ 次级别确认: ' if ok5 else '✗ 次级别未确认(降级,不拦截): ')+note5, '第44课: 逐级处理 —— 15分钟的转折由5分钟走势确认'))
|
| 693 |
+
if not ok5: downgrade = True
|
| 694 |
+
|
| 695 |
+
m1_an = None; m1_confirmed = None
|
| 696 |
+
do_1m = confirmed and (m5_confirmed is True)
|
| 697 |
+
if do_1m and df_1 is not None and len(df_1) >= 30:
|
| 698 |
+
try: m1_an = _MAKE_ANALYZER('1m', df_1)
|
| 699 |
+
except Exception: m1_an = None
|
| 700 |
+
if not do_1m:
|
| 701 |
+
pass
|
| 702 |
+
elif df_1 is None:
|
| 703 |
+
chain.append(('1m','无1分钟数据 → 缺区间套最末级印证, 信号降级','第44课: 区间套 —— 5分钟的转折需1分钟走势逐级确认')); downgrade = True
|
| 704 |
+
elif m1_an is None:
|
| 705 |
+
chain.append(('1m','1分钟数据不足(<30根) → 缺最末级印证, 信号降级','第44课: 区间套 —— 5分钟的转折需1分钟走势逐级确认')); downgrade = True
|
| 706 |
+
else:
|
| 707 |
+
ok1, note1 = self._confirm_1m(m1_an, is_buy, daily_sig.kind,
|
| 708 |
+
parent_sig=(daily_sig if is_sell else None))
|
| 709 |
+
m1_confirmed = ok1
|
| 710 |
+
chain.append(('1m', ('✓ 末级确认: ' if ok1 else '✗ 末级未确认(降级,不拦截): ')+note1, '第44课: 区间套 —— 5分钟的转折由1分钟走势确认'))
|
| 711 |
+
if not ok1: downgrade = True
|
| 712 |
+
|
| 713 |
+
# ── L24规则: 周线上涨 + 日线【盘整背驰】(非趋势背驰)的S1/S2 ──
|
| 714 |
+
# 第24课: 某级别的背驰导致该级别的转折; 日线背驰若周线未同步背驰,
|
| 715 |
+
# 只构成日线级别的调整, 不是大顶 → 不全清仓, 转入区间套布防做短差:
|
| 716 |
+
# 真转折由布防线(嵌套背驰/破30m中枢ZD/峰值回落)兜底, 趋势延续则由
|
| 717 |
+
# 撤防机制(强势新高消化背驰, 第26课)继续骑中枢上移(第91课①)。
|
| 718 |
+
if (self.CFG.get('sell_nested_interval') and self.CFG.get('l24_weekly_uptrend_arm')
|
| 719 |
+
and is_sell and daily_sig.kind in ('S1', 'S2') and weekly_dir == 'up_trend'):
|
| 720 |
+
_dg = getattr(daily_sig, 'diverge_grade', None)
|
| 721 |
+
if _dg is not None and not getattr(_dg, 'is_trend_divergence', False):
|
| 722 |
+
_arm_zd = (m30_an.pivots[-1].zd if (m30_an is not None and m30_an.n_pivots) else None)
|
| 723 |
+
_ex = daily_sig.extras or {}
|
| 724 |
+
_arm_high = _ex.get('c_high') or _ex.get('prev_high') or cur_price
|
| 725 |
+
chain.append(('L24', f'周线上涨 + 日线{daily_sig.kind}仅为盘整背驰(非趋势背驰) '
|
| 726 |
+
f'→ 第24课: 不构成周线级别大顶, 不全清 → 转区间套布防短差'
|
| 727 |
+
f'(布防线: 30m中枢ZD{f"¥{_arm_zd:.3f}" if _arm_zd else "暂无"}/'
|
| 728 |
+
f'峰值回落{SELL_ARM_PEAK_DROP:.0%}; 强势新高则撤防骑趋势)',
|
| 729 |
+
'第24课: 日线背驰除非周线同步背驰, 否则只造成日线级别调整'))
|
| 730 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 731 |
+
action='HOLD', confidence='MEDIUM', final_kind=daily_sig.kind,
|
| 732 |
+
cur_price=cur_price, chain=chain,
|
| 733 |
+
note=f'L24: 周线上涨中的日线盘整背驰{daily_sig.kind}, 布防短差不全清',
|
| 734 |
+
sell_armed=True, arm_zd=_arm_zd,
|
| 735 |
+
arm_high=float(_arm_high) if _arm_high else None,
|
| 736 |
+
diagnostics=diagnostics, monthly=mov)
|
| 737 |
+
|
| 738 |
+
action = 'BUY' if is_buy else 'SELL'
|
| 739 |
+
if self.CFG.get('mode') == 'long' and is_sell and daily_sig.kind in ('S1', 'S2'):
|
| 740 |
+
big_up_long = ((wv is not None and wv.trend == 'up_trend') or
|
| 741 |
+
(mov is not None and monthly_dir == 'up_trend'))
|
| 742 |
+
ma5m_ok = True
|
| 743 |
+
if len(df_mo) >= 5:
|
| 744 |
+
ma5m = float(df_mo['close'].tail(5).mean())
|
| 745 |
+
ma5m_ok = cur_price >= ma5m
|
| 746 |
+
if big_up_long and ma5m_ok:
|
| 747 |
+
chain.append(('mode', f'长线模式: 日线{daily_sig.kind}非周线级别转折且大方向上涨 → 持有(操作级别=周线)', '第72课: 看周线则日线调整不构成卖段; 第61课大级别不因小级别震荡卖'))
|
| 748 |
+
# 长线模式持有也挂上布防线(第44课): 真转折时不至于全程坐滑梯
|
| 749 |
+
_arm_zd = (m30_an.pivots[-1].zd if (m30_an is not None and m30_an.n_pivots) else None)
|
| 750 |
+
_ex = daily_sig.extras or {}
|
| 751 |
+
_arm_high = _ex.get('c_high') or _ex.get('prev_high') or cur_price
|
| 752 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 753 |
+
action='HOLD', confidence='MEDIUM', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 754 |
+
chain=chain, note=f'长线模式持有: 日线{daily_sig.kind}属次级别调整, 周线方向未转',
|
| 755 |
+
sell_armed=bool(self.CFG.get('sell_nested_interval')),
|
| 756 |
+
arm_zd=_arm_zd, arm_high=float(_arm_high) if _arm_high else None,
|
| 757 |
+
diagnostics=diagnostics, monthly=mov)
|
| 758 |
+
if not confirmed:
|
| 759 |
+
if is_sell:
|
| 760 |
+
# ── 卖点区间套布防 (第24/44课): 背驰已现但次级别动能未竭 ──
|
| 761 |
+
# 不立即卖(防卖飞), 也绝不傻等到三卖(防坐滑梯): 持仓转入布防,
|
| 762 |
+
# 由回测端的布防线(嵌套背驰出现/破30m中枢ZD/峰值回落阈值)收割。
|
| 763 |
+
# 布防仅用于S1的精确定顶。S2是一卖后的【反抽】卖点(第15课):
|
| 764 |
+
# 上方空间被一卖高点封死, 反抽本身就是用来卖的, "等次级别动能衰竭"
|
| 765 |
+
# 与其性质矛盾 → S2未印证时维持"先卖再说"(走下方LOW置信卖出)。
|
| 766 |
+
if (self.CFG.get('sell_nested_interval') and daily_sig.kind == 'S1'):
|
| 767 |
+
arm_zd = (m30_an.pivots[-1].zd if (m30_an is not None and m30_an.n_pivots) else None)
|
| 768 |
+
ex = daily_sig.extras or {}
|
| 769 |
+
arm_high = ex.get('c_high') or ex.get('prev_high') or cur_price
|
| 770 |
+
chain.append(('卖点布防',
|
| 771 |
+
f'日线{daily_sig.kind}背驰已现但次级别动能未竭 → 不立即卖(防卖飞), '
|
| 772 |
+
f'转入区间套布防: ①次级别出现嵌套顶背驰即卖 '
|
| 773 |
+
f'②跌破30m最近中枢下沿ZD{f"¥{arm_zd:.3f}" if arm_zd else "(暂无30m中枢)"}即卖 '
|
| 774 |
+
f'③较布防后峰值回落{SELL_ARM_PEAK_DROP:.0%}即卖',
|
| 775 |
+
'第44课区间套: 背驰段中套背驰段定精确高点; 第24课: 次级别未背驰, 大级别顶未到'))
|
| 776 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 777 |
+
action='HOLD', confidence='MEDIUM', final_kind=daily_sig.kind,
|
| 778 |
+
cur_price=cur_price, chain=chain,
|
| 779 |
+
note=f'区间套布防中: 日线{daily_sig.kind}背驰段内, 等次级别嵌套背驰收敛后高位兑现(第44课)',
|
| 780 |
+
sell_armed=True, arm_zd=arm_zd,
|
| 781 |
+
arm_high=float(arm_high) if arm_high else None,
|
| 782 |
+
confidence_reasons=[f'布防原因: {confirm_note}'],
|
| 783 |
+
diagnostics=diagnostics, monthly=mov)
|
| 784 |
+
big_up_sell = ((wv is not None and wv.trend == 'up_trend') or
|
| 785 |
+
(mov is not None and monthly_dir == 'up_trend'))
|
| 786 |
+
if self.CFG.get('require_sublevel_sell_confirm') and big_up_sell:
|
| 787 |
+
chain.append(('sell_gate', f'日线{daily_sig.kind}卖点但次级别未印证, 且大方向上涨 → 持有(L61: 大级别不因小级别震荡卖)', '第61课: 区间套确认; 大级别操作不因小级别震荡清仓'))
|
| 788 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 789 |
+
action='HOLD', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 790 |
+
chain=chain, note=f'日线{daily_sig.kind}卖点次级别未印证+大方向上涨, 持有等确认: {confirm_note}',
|
| 791 |
+
confidence_reasons=[f'次级别未印证持有: {confirm_note}'],
|
| 792 |
+
diagnostics=diagnostics, monthly=mov)
|
| 793 |
+
chain.append(('sell_gate',f'日线{daily_sig.kind}卖点已成立, 次级别未印证只降级不拦截','第14/15/21课: 买点买、卖点卖; 区间套用于精确定位, 不是否决卖点'))
|
| 794 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 795 |
+
action='SELL', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 796 |
+
chain=chain, note=f'日线{daily_sig.kind}卖点先执行; 次级别未印证仅降级: {confirm_note}',
|
| 797 |
+
confidence_reasons=[f'次级别未印证: {confirm_note}'],
|
| 798 |
+
diagnostics=diagnostics, monthly=mov)
|
| 799 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 800 |
+
action='WATCH', confidence='LOW', final_kind=daily_sig.kind, cur_price=cur_price,
|
| 801 |
+
chain=chain, blocked_reason=f'次级别未印证({confirm_note})',
|
| 802 |
+
note='日线有买/非S1卖信号但次级别未确认 -- 等次级别出信号再动手',
|
| 803 |
+
diagnostics=diagnostics, monthly=mov)
|
| 804 |
+
|
| 805 |
+
score = 100; conf_reasons = []
|
| 806 |
+
def _penalty(pts, reason):
|
| 807 |
+
nonlocal score
|
| 808 |
+
score -= pts; conf_reasons.append(f'(-{pts}) {reason}')
|
| 809 |
+
if mov is not None and monthly_dir == 'down_trend' and is_buy:
|
| 810 |
+
_penalty(25, '第69课: 月线大方向下跌, 日线买点属逆大方向的转折尝试')
|
| 811 |
+
trend_piv_lt2 = (daily_an is not None and daily_an.n_trend_pivots < 2)
|
| 812 |
+
if trend_piv_lt2 and daily_sig.kind in ('B1', 'S1'):
|
| 813 |
+
_penalty(20, f'第27课: 背驰段前仅{daily_an.n_trend_pivots}个中枢(<2), 盘整背驰非趋势背驰')
|
| 814 |
+
if daily_an is not None and daily_an.trend == 'consolidation':
|
| 815 |
+
_penalty(15, '第29课: 日线处盘整结构, 盘整中转折力度弱')
|
| 816 |
+
daily_dg = getattr(daily_sig, 'diverge_grade', None)
|
| 817 |
+
if daily_dg is not None and daily_dg.grade == 'WEAK':
|
| 818 |
+
trig = '面积' if daily_dg.area_ok else 'DIF极值'
|
| 819 |
+
_penalty(15, f'第27课: 背驰仅满足{trig}单一判据(WEAK), 非标准背驰')
|
| 820 |
+
if (daily_dg is not None and daily_dg.grade in ('STRONG', 'WEAK')
|
| 821 |
+
and not daily_dg.is_trend_divergence and not trend_piv_lt2
|
| 822 |
+
and daily_sig.kind in ('B1', 'S1')):
|
| 823 |
+
_penalty(10, '第27课: 背驰段为盘整背驰(非趋势背驰), 力度偏弱')
|
| 824 |
+
if downgrade:
|
| 825 |
+
_penalty(12, '第44课: 区间套次级别未完全印证(精度降级, 非证伪)')
|
| 826 |
+
if daily_sig.kind in ('B2', 'S2'):
|
| 827 |
+
has_delayed = any(lvl in ('30m', '15m', '5m', '1m') and '延后确认' in concl
|
| 828 |
+
for lvl, concl, _ in chain)
|
| 829 |
+
if has_delayed:
|
| 830 |
+
_penalty(8, '二/二卖由次级别延后确认(非次级别一买/一卖精确定位), 精度略降')
|
| 831 |
+
ex = daily_sig.extras or {}
|
| 832 |
+
if daily_sig.kind == 'B3':
|
| 833 |
+
missing = []
|
| 834 |
+
if ex.get('b1_price') is None:
|
| 835 |
+
missing.append('一买')
|
| 836 |
+
if ex.get('b2_price') is None:
|
| 837 |
+
missing.append('二买')
|
| 838 |
+
if missing:
|
| 839 |
+
_penalty(6, '第20/21课: 三买未能定位对应' + '/'.join(missing) + ', 质量略降')
|
| 840 |
+
if ex.get('late_trend_b3'):
|
| 841 |
+
_penalty(12, '第20/92课: 第二个以上同向中枢后的三买, 操作意义下降')
|
| 842 |
+
if daily_sig.kind == 'S3':
|
| 843 |
+
missing = []
|
| 844 |
+
if ex.get('s1_price') is None:
|
| 845 |
+
missing.append('一卖')
|
| 846 |
+
if ex.get('s2_price') is None:
|
| 847 |
+
missing.append('二卖')
|
| 848 |
+
if missing:
|
| 849 |
+
_penalty(6, '第20/21课: 三卖未能定位对应' + '/'.join(missing) + ', 质量略降')
|
| 850 |
+
if daily_dg is not None and daily_dg.grade == 'STRONG' and daily_dg.is_trend_divergence:
|
| 851 |
+
score += 10; conf_reasons.append('(+10) 第27课: 标准趋势背驰(>=2中枢+面积+DIF), 高质量')
|
| 852 |
+
# ── 卖点区间套加分: 30m嵌套顶背驰精确定位(卖在高位区) ──
|
| 853 |
+
if is_sell and any(lvl == '30m' and '嵌套顶背驰' in concl for lvl, concl, _ in chain):
|
| 854 |
+
score += 8; conf_reasons.append('(+8) 第44课: 30m嵌套顶背驰精确定位, 卖点落在高位区')
|
| 855 |
+
score = max(0, min(110, score))
|
| 856 |
+
confidence = 'HIGH' if score >= 70 else ('MEDIUM' if score >= 45 else 'LOW')
|
| 857 |
+
if conf_reasons:
|
| 858 |
+
chain.append(('置信度', f'{confidence}(评分{score}) ← ' + '; '.join(conf_reasons),
|
| 859 |
+
'第21课: 一二三类买卖点是位置分类非质量排序; 置信度按原著力度条件评分'))
|
| 860 |
+
else:
|
| 861 |
+
chain.append(('置信度', f'HIGH(评分{score}) ← 力度条件全部满足',
|
| 862 |
+
'第27/29/43课: 方向一致+趋势背驰+趋势结构'))
|
| 863 |
+
note_parts = []
|
| 864 |
+
if conf_reasons:
|
| 865 |
+
note_parts.append(f'置信评分明细: ' + '; '.join(conf_reasons))
|
| 866 |
+
n_lvl = 3 + (self.df_60m is not None) + (self.df_15m is not None) + (self.df_5m is not None) + (self.df_1m is not None)
|
| 867 |
+
lvl_txt = {3:'三级别',4:'四级别',5:'五级别',6:'六级别',7:'七级别'}.get(n_lvl, f'{n_lvl}级别')
|
| 868 |
+
m5_txt = ('/5m已印证' if m5_confirmed is True else '/5m未印证(已降级)' if m5_confirmed is False else '')
|
| 869 |
+
m1_txt = ('/1m已印证' if m1_confirmed is True else '/1m未印证(已降级)' if m1_confirmed is False else '')
|
| 870 |
+
note_parts.append(f'{lvl_txt}联立通过: 周线{weekly_dir}/日线{daily_sig.kind}/30m已印证{m5_txt}{m1_txt}')
|
| 871 |
+
note = ' | '.join(note_parts)
|
| 872 |
+
return MultiLevelSignal(code=self.code, analysis_date=last_date, weekly=wv, daily=dv, m30=_mv(),
|
| 873 |
+
action=action, confidence=confidence, final_kind=daily_sig.kind, cur_price=cur_price,
|
| 874 |
+
chain=chain, note=note, confidence_reasons=conf_reasons, diagnostics=diagnostics, monthly=mov)
|
| 875 |
+
|
| 876 |
+
@staticmethod
|
| 877 |
+
def _no_signal_decision(wv, dv, cur_price):
|
| 878 |
+
if wv is not None and wv.trend == 'up_trend' and dv.trend == 'up_trend':
|
| 879 |
+
if dv.zg is not None and cur_price > dv.zg:
|
| 880 |
+
return ('HOLD','NONE', f'周线+日线均上涨且价({cur_price:.2f})在日线ZG({dv.zg:.2f})上, 继续持有等卖点(第108课: 买点入,持有等卖点)')
|
| 881 |
+
return ('HOLD','NONE','周线日线均上涨, 趋势中持有')
|
| 882 |
+
if wv is not None and wv.trend == 'down_trend':
|
| 883 |
+
return ('WATCH','NONE','周线下跌趋势, 无日线买点 → 空仓观望, 不抄底')
|
| 884 |
+
return ('WATCH','NONE', f'无明确信号(周线{wv.trend if wv else "?"}/日线{dv.trend}), 观望')
|
finetune_data.py
CHANGED
|
@@ -1,105 +1,147 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
-
import json
|
| 18 |
import os
|
| 19 |
-
import
|
| 20 |
-
import
|
| 21 |
-
import datetime as dt
|
| 22 |
|
| 23 |
-
import
|
| 24 |
-
|
| 25 |
-
_PAIRS = os.path.join(paths.DATASET_DIR, "pairs.jsonl")
|
| 26 |
-
_lock = threading.Lock()
|
| 27 |
-
|
| 28 |
-
INSTRUCTION = ("You are an equity analyst. Based only on this factual read of a "
|
| 29 |
-
"US stock's multi-timeframe Chan-theory verdict, write a short "
|
| 30 |
-
"plain-English summary for a long-term holder: the situation "
|
| 31 |
-
"today, whether to act or wait, and the key price levels. "
|
| 32 |
-
"Max 90 words, no disclaimers.")
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def _clean(text: str) -> str:
|
| 36 |
-
# strip stray think tags and any "AI narrative ..." UI prefix
|
| 37 |
-
text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
|
| 38 |
-
text = re.sub(r"^🤖\s*\*\*AI narrative[^\n]*\*\*\s*", "", text)
|
| 39 |
-
text = text.replace("🤖 **AI narrative (Translator sub-agent · Qwen3-1.7B):**", "")
|
| 40 |
-
return text.strip()
|
| 41 |
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
try:
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
except OSError:
|
| 55 |
pass
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
try:
|
| 74 |
-
with open(_PAIRS, encoding="utf-8") as f:
|
| 75 |
-
for line in f:
|
| 76 |
-
line = line.strip()
|
| 77 |
-
if not line:
|
| 78 |
-
continue
|
| 79 |
-
try:
|
| 80 |
-
r = json.loads(line)
|
| 81 |
-
except ValueError:
|
| 82 |
-
continue
|
| 83 |
-
key = (r.get("input", ""), r.get("output", ""))
|
| 84 |
-
if key in seen:
|
| 85 |
-
continue
|
| 86 |
-
seen.add(key)
|
| 87 |
-
rows.append(r)
|
| 88 |
-
except OSError:
|
| 89 |
-
return ""
|
| 90 |
-
stamp = dt.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
| 91 |
-
out = os.path.join(paths.DATASET_DIR, f"chan_sft_{stamp}.jsonl")
|
| 92 |
try:
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
data_us.py — US market data layer (yfinance), replacing baostock/pytdx.
|
| 3 |
+
|
| 4 |
+
Levels & history limits (Yahoo Finance API constraints):
|
| 5 |
+
daily : 10 years (weekly / monthly are resampled from daily
|
| 6 |
+
by chan_multilevel.resample_weekly/_monthly)
|
| 7 |
+
60m : last 730 days
|
| 8 |
+
30m/15m : last 60 days
|
| 9 |
+
5m : last 60 days
|
| 10 |
+
1m : last 7 days only → too short for Chan decomposition, NOT used.
|
| 11 |
+
MultiLevelChan handles a missing 1m level gracefully (skips it).
|
| 12 |
+
|
| 13 |
+
Output schema (identical to the original A-share loaders):
|
| 14 |
+
date, open, close, high, low, volume, amount
|
| 15 |
+
`amount` (turnover) is approximated as close × volume (Yahoo has no turnover field).
|
| 16 |
+
|
| 17 |
+
All downloads are cached to parquet under ./_cache_us/<TICKER>/<level>.parquet
|
| 18 |
+
and refreshed when stale (daily: >12h old, intraday: >2h old) or on force=True.
|
| 19 |
"""
|
| 20 |
from __future__ import annotations
|
| 21 |
|
|
|
|
| 22 |
import os
|
| 23 |
+
import time
|
| 24 |
+
import traceback
|
|
|
|
| 25 |
|
| 26 |
+
import pandas as pd
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
+
import paths
|
| 29 |
|
| 30 |
+
CACHE_DIR = os.environ.get("CHAN_CACHE_DIR", paths.CACHE_DIR)
|
| 31 |
+
|
| 32 |
+
LEVELS = {
|
| 33 |
+
# level: (yfinance interval, period) — Yahoo's max history per interval
|
| 34 |
+
"d": ("1d", "10y"),
|
| 35 |
+
"60m": ("60m", "730d"),
|
| 36 |
+
"30m": ("30m", "60d"),
|
| 37 |
+
"15m": ("15m", "60d"),
|
| 38 |
+
"5m": ("5m", "60d"),
|
| 39 |
+
"1m": ("1m", "7d"), # only 7 days available; short but usable for the
|
| 40 |
+
# finest nested-interval confirmation when present
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
_STALE_SECONDS = {"d": 12 * 3600, "60m": 2 * 3600, "30m": 2 * 3600,
|
| 44 |
+
"15m": 2 * 3600, "5m": 2 * 3600, "1m": 1800}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _cache_path(ticker: str, level: str) -> str:
|
| 48 |
+
d = os.path.join(CACHE_DIR, ticker.upper().replace("/", "_"))
|
| 49 |
+
os.makedirs(d, exist_ok=True)
|
| 50 |
+
return os.path.join(d, f"{level}.parquet")
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _normalize(df: pd.DataFrame) -> pd.DataFrame:
|
| 54 |
+
"""yfinance frame → engine schema (date/open/close/high/low/volume/amount)."""
|
| 55 |
+
if df is None or len(df) == 0:
|
| 56 |
+
return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
|
| 57 |
+
d = df.copy()
|
| 58 |
+
if isinstance(d.columns, pd.MultiIndex): # yf>=0.2 returns MultiIndex sometimes
|
| 59 |
+
d.columns = [c[0] if isinstance(c, tuple) else c for c in d.columns]
|
| 60 |
+
d = d.reset_index()
|
| 61 |
+
# index column may be 'Date' or 'Datetime'
|
| 62 |
+
for cand in ("Datetime", "Date", "index"):
|
| 63 |
+
if cand in d.columns:
|
| 64 |
+
d = d.rename(columns={cand: "date"})
|
| 65 |
+
break
|
| 66 |
+
d.columns = [str(c).lower() for c in d.columns]
|
| 67 |
+
keep = {"date", "open", "high", "low", "close", "volume"}
|
| 68 |
+
d = d[[c for c in d.columns if c in keep]]
|
| 69 |
+
d["date"] = pd.to_datetime(d["date"])
|
| 70 |
+
# strip timezone so comparisons with naive Timestamps in the engine work
|
| 71 |
try:
|
| 72 |
+
d["date"] = d["date"].dt.tz_localize(None)
|
| 73 |
+
except (TypeError, AttributeError):
|
|
|
|
| 74 |
pass
|
| 75 |
+
d = d.dropna(subset=["open", "high", "low", "close"])
|
| 76 |
+
d = d.sort_values("date").reset_index(drop=True)
|
| 77 |
+
d["amount"] = d["close"] * d.get("volume", 0)
|
| 78 |
+
return d[["date", "open", "close", "high", "low", "volume", "amount"]]
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def load_level(ticker: str, level: str, force: bool = False) -> pd.DataFrame:
|
| 82 |
+
"""Load one level for a ticker, using parquet cache when fresh."""
|
| 83 |
+
assert level in LEVELS, f"unknown level {level}"
|
| 84 |
+
path = _cache_path(ticker, level)
|
| 85 |
+
if not force and os.path.exists(path):
|
| 86 |
+
age = time.time() - os.path.getmtime(path)
|
| 87 |
+
if age < _STALE_SECONDS[level]:
|
| 88 |
+
try:
|
| 89 |
+
return pd.read_parquet(path)
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
try:
|
| 93 |
+
import yfinance as yf
|
| 94 |
+
interval, period = LEVELS[level]
|
| 95 |
+
raw = yf.Ticker(ticker).history(period=period, interval=interval,
|
| 96 |
+
auto_adjust=True, actions=False)
|
| 97 |
+
df = _normalize(raw)
|
| 98 |
+
if len(df):
|
| 99 |
+
df.to_parquet(path, index=False)
|
| 100 |
+
return df
|
| 101 |
+
except Exception:
|
| 102 |
+
traceback.print_exc()
|
| 103 |
+
# network failed → fall back to stale cache if any
|
| 104 |
+
if os.path.exists(path):
|
| 105 |
+
try:
|
| 106 |
+
return pd.read_parquet(path)
|
| 107 |
+
except Exception:
|
| 108 |
+
pass
|
| 109 |
+
return pd.DataFrame(columns=["date", "open", "close", "high", "low", "volume", "amount"])
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# Full nested-interval set (区间套): the more sub-levels confirm, the more
|
| 113 |
+
# precise the buy/sell point. We fetch the deepest Yahoo allows. 1m has only
|
| 114 |
+
# 7 days of history — included when present, skipped gracefully otherwise.
|
| 115 |
+
# Downloads are parallel + cached, so the extra levels cost little wall-time.
|
| 116 |
+
FULL_LEVELS = ("d", "60m", "30m", "15m", "5m", "1m")
|
| 117 |
+
FAST_LEVELS = FULL_LEVELS # default everywhere; alias kept for older callers
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def load_levels(ticker: str, levels=FAST_LEVELS, force: bool = False) -> dict:
|
| 121 |
+
return {lvl: load_level(ticker, lvl, force=force) for lvl in levels}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def load_all_levels(ticker: str, force: bool = False) -> dict:
|
| 125 |
+
"""Return {'d':…, '60m':…, '30m':…, '15m':…, '5m':…} (1m intentionally absent)."""
|
| 126 |
+
return {lvl: load_level(ticker, lvl, force=force) for lvl in LEVELS}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def prefetch(tickers, levels=FAST_LEVELS, force: bool = False, workers: int = 5,
|
| 130 |
+
budget_s: int = 45):
|
| 131 |
+
"""Download all (ticker, level) pairs in parallel with a hard time budget.
|
| 132 |
+
Yahoo rate-limits datacenter IPs; without a budget one throttled request
|
| 133 |
+
could hang the whole Run-analysis click. Whatever isn't fetched in time is
|
| 134 |
+
skipped — the engine analyzes from daily/cached data and the next run
|
| 135 |
+
picks up the rest."""
|
| 136 |
+
from concurrent.futures import ThreadPoolExecutor, wait
|
| 137 |
+
jobs = [(t, lvl) for t in tickers for lvl in levels]
|
| 138 |
+
ex = ThreadPoolExecutor(max_workers=workers)
|
| 139 |
+
futs = [ex.submit(load_level, t, lvl, force) for t, lvl in jobs]
|
| 140 |
+
done, not_done = wait(futs, timeout=budget_s)
|
| 141 |
+
ex.shutdown(wait=False, cancel_futures=True)
|
| 142 |
+
return len(done), len(not_done)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def last_daily_date(ticker: str):
|
| 146 |
+
df = load_level(ticker, "d")
|
| 147 |
+
return None if df.empty else pd.Timestamp(df["date"].iloc[-1])
|
fonts.css
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
COLOR — Chan Compass · Spectrum 2
|
| 3 |
+
Two layers:
|
| 4 |
+
1. PRIMITIVES — the raw Spectrum 2 scales (--s2-gray-100, --s2-blue-900…)
|
| 5 |
+
2. SEMANTICS — purpose-named aliases the UI references (--text-body,
|
| 6 |
+
--surface-card, --accent…)
|
| 7 |
+
Always consume semantics in components; reach for a primitive only
|
| 8 |
+
when no semantic fits. Light theme is the default; .s2-dark flips the
|
| 9 |
+
semantic layer (primitives are not re-pointed here for simplicity).
|
| 10 |
+
============================================================ */
|
| 11 |
+
|
| 12 |
+
:root {
|
| 13 |
+
/* ---- Gray (light) ---- */
|
| 14 |
+
--s2-gray-25: #ffffff;
|
| 15 |
+
--s2-gray-50: #f8f8f8;
|
| 16 |
+
--s2-gray-75: #f3f3f3;
|
| 17 |
+
--s2-gray-100: #e6e6e6;
|
| 18 |
+
--s2-gray-200: #d5d5d5;
|
| 19 |
+
--s2-gray-300: #b1b1b1;
|
| 20 |
+
--s2-gray-400: #909090;
|
| 21 |
+
--s2-gray-500: #6d6d6d;
|
| 22 |
+
--s2-gray-600: #464646;
|
| 23 |
+
--s2-gray-700: #292929;
|
| 24 |
+
--s2-gray-800: #1b1b1b;
|
| 25 |
+
--s2-gray-900: #000000;
|
| 26 |
+
|
| 27 |
+
/* ---- Blue (accent family) ---- */
|
| 28 |
+
--s2-blue-100: #e0f2ff;
|
| 29 |
+
--s2-blue-200: #cae8ff;
|
| 30 |
+
--s2-blue-300: #98cbfe;
|
| 31 |
+
--s2-blue-400: #6fb7fb;
|
| 32 |
+
--s2-blue-500: #4ba0ec;
|
| 33 |
+
--s2-blue-600: #2c84e0;
|
| 34 |
+
--s2-blue-700: #1473e6;
|
| 35 |
+
--s2-blue-800: #0a6fd6;
|
| 36 |
+
--s2-blue-900: #0265dc; /* accent default */
|
| 37 |
+
--s2-blue-1000: #0054b6; /* accent hover */
|
| 38 |
+
--s2-blue-1100: #00418f; /* accent down */
|
| 39 |
+
|
| 40 |
+
/* ---- Green (positive) ---- */
|
| 41 |
+
--s2-green-100: #ebf9ee;
|
| 42 |
+
--s2-green-400: #6cc788;
|
| 43 |
+
--s2-green-700: #15924a;
|
| 44 |
+
--s2-green-900: #007a39; /* positive default */
|
| 45 |
+
--s2-green-1000: #006d31;
|
| 46 |
+
--s2-green-1100: #00601f;
|
| 47 |
+
|
| 48 |
+
/* ---- Red (negative) ---- */
|
| 49 |
+
--s2-red-100: #ffefef;
|
| 50 |
+
--s2-red-400: #ff9b88;
|
| 51 |
+
--s2-red-700: #e34850;
|
| 52 |
+
--s2-red-900: #d7373f; /* negative default */
|
| 53 |
+
--s2-red-1000: #c0272d;
|
| 54 |
+
--s2-red-1100: #a01a1f;
|
| 55 |
+
|
| 56 |
+
/* ---- Orange (notice) ---- */
|
| 57 |
+
--s2-orange-100: #fdf0e1;
|
| 58 |
+
--s2-orange-400: #f8af5a;
|
| 59 |
+
--s2-orange-700: #cb6f10;
|
| 60 |
+
--s2-orange-900: #b25309; /* notice default */
|
| 61 |
+
--s2-orange-1000: #99490b;
|
| 62 |
+
|
| 63 |
+
/* ---- Indigo (informative accent / charts) ---- */
|
| 64 |
+
--s2-indigo-700: #5258e4;
|
| 65 |
+
--s2-indigo-900: #3d43d0;
|
| 66 |
+
|
| 67 |
+
/* ---- Purple (decorative / hero gradient stop) ---- */
|
| 68 |
+
--s2-purple-700: #8c42d6;
|
| 69 |
+
--s2-purple-900: #7326d3;
|
| 70 |
+
|
| 71 |
+
/* ============================================================
|
| 72 |
+
SEMANTIC ALIASES — what components actually reference
|
| 73 |
+
============================================================ */
|
| 74 |
+
|
| 75 |
+
/* Surfaces & framing (Spectrum 2 background layering) */
|
| 76 |
+
--canvas: var(--s2-gray-50); /* app background */
|
| 77 |
+
--surface-card: var(--s2-gray-25); /* cards, panels */
|
| 78 |
+
--surface-elevated:var(--s2-gray-25); /* popovers, menus */
|
| 79 |
+
--surface-subtle: var(--s2-gray-75); /* table head, wells */
|
| 80 |
+
--surface-sunken: var(--s2-gray-75); /* inset fields */
|
| 81 |
+
|
| 82 |
+
/* Text */
|
| 83 |
+
--text-heading: var(--s2-gray-800);
|
| 84 |
+
--text-body: var(--s2-gray-700);
|
| 85 |
+
--text-secondary: var(--s2-gray-600);
|
| 86 |
+
--text-muted: var(--s2-gray-500);
|
| 87 |
+
--text-disabled: var(--s2-gray-400);
|
| 88 |
+
--text-on-accent: #ffffff;
|
| 89 |
+
|
| 90 |
+
/* Borders & dividers */
|
| 91 |
+
--border-hairline: var(--s2-gray-100);
|
| 92 |
+
--border-default: var(--s2-gray-200);
|
| 93 |
+
--border-strong: var(--s2-gray-300);
|
| 94 |
+
--border-field: var(--s2-gray-300);
|
| 95 |
+
|
| 96 |
+
/* Accent (Spectrum blue) */
|
| 97 |
+
--accent: var(--s2-blue-900);
|
| 98 |
+
--accent-hover: var(--s2-blue-1000);
|
| 99 |
+
--accent-down: var(--s2-blue-1100);
|
| 100 |
+
--accent-subtle: var(--s2-blue-100);
|
| 101 |
+
--accent-text: var(--s2-blue-1000);
|
| 102 |
+
|
| 103 |
+
/* Semantic status */
|
| 104 |
+
--positive: var(--s2-green-900);
|
| 105 |
+
--positive-hover: var(--s2-green-1000);
|
| 106 |
+
--positive-subtle: var(--s2-green-100);
|
| 107 |
+
--negative: var(--s2-red-900);
|
| 108 |
+
--negative-hover: var(--s2-red-1000);
|
| 109 |
+
--negative-subtle: var(--s2-red-100);
|
| 110 |
+
--notice: var(--s2-orange-900);
|
| 111 |
+
--notice-subtle: var(--s2-orange-100);
|
| 112 |
+
--informative: var(--s2-blue-900);
|
| 113 |
+
--informative-subtle: var(--s2-blue-100);
|
| 114 |
+
|
| 115 |
+
/* Focus ring */
|
| 116 |
+
--focus-ring: var(--s2-blue-900);
|
| 117 |
+
|
| 118 |
+
/* Market direction (this product's domain colors) */
|
| 119 |
+
--up: var(--s2-green-900); /* gains / BUY */
|
| 120 |
+
--down: var(--s2-red-900); /* losses / SELL */
|
| 121 |
+
--flat: var(--s2-gray-500); /* HOLD / WAIT */
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/* ---- Dark theme: flip the semantic layer only ---- */
|
| 125 |
+
.s2-dark {
|
| 126 |
+
--canvas: #1d1d1d;
|
| 127 |
+
--surface-card: #262626;
|
| 128 |
+
--surface-elevated:#2c2c2c;
|
| 129 |
+
--surface-subtle: #222222;
|
| 130 |
+
--surface-sunken: #1a1a1a;
|
| 131 |
+
|
| 132 |
+
--text-heading: #f2f2f2;
|
| 133 |
+
--text-body: #e0e0e0;
|
| 134 |
+
--text-secondary: #b8b8b8;
|
| 135 |
+
--text-muted: #8f8f8f;
|
| 136 |
+
--text-disabled: #6a6a6a;
|
| 137 |
+
|
| 138 |
+
--border-hairline: #383838;
|
| 139 |
+
--border-default: #4a4a4a;
|
| 140 |
+
--border-strong: #5e5e5e;
|
| 141 |
+
--border-field: #5e5e5e;
|
| 142 |
+
|
| 143 |
+
--accent: #2680eb;
|
| 144 |
+
--accent-hover: #4ba0ec;
|
| 145 |
+
--accent-down: #6fb7fb;
|
| 146 |
+
--accent-subtle: #1a3a5c;
|
| 147 |
+
--accent-text: #6fb7fb;
|
| 148 |
+
}
|
llm_local.py
CHANGED
|
@@ -1,307 +1,288 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
→ the multi-step Auto Research agent's report writing.
|
| 12 |
-
|
| 13 |
-
Both run through llama.cpp (llama-cpp-python) and are far below the 32B cap;
|
| 14 |
-
the fast worker doubles as the "Tiny Titan" (≤4B) story. ~5 GB RAM total on a
|
| 15 |
-
32 GB Space. Earns "Off the Grid" + "Llama Champion".
|
| 16 |
"""
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
import re
|
| 21 |
-
import
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
from
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
WORKER_LABEL = {
|
| 53 |
-
"translator": "Summary sub-agent (Signals · Explain)",
|
| 54 |
-
"narrator": "Narrator sub-agent (Sector Rotation)",
|
| 55 |
-
"reporter": "Reporter sub-agent (News · Research support)",
|
| 56 |
-
"analyst": "Analyst sub-agent (Auto Research)",
|
| 57 |
-
}
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def _mk(model):
|
| 61 |
-
return {"model": model, "llm": None, "lock": threading.Lock(),
|
| 62 |
-
"load_lock": threading.Lock(), "stage": "idle", "detail": "", "ts": None}
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
WORKERS = {
|
| 66 |
-
"translator": _mk(TRANSLATOR_MODEL),
|
| 67 |
-
"narrator": _mk(FAST_MODEL),
|
| 68 |
-
"reporter": _mk(FAST_MODEL),
|
| 69 |
-
"analyst": _mk(DEFAULT_MODEL),
|
| 70 |
}
|
| 71 |
-
# legacy aliases
|
| 72 |
-
_ALIAS = {"fast": "translator", "deep": "analyst"}
|
| 73 |
-
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
try:
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
return ""
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
try:
|
| 165 |
-
if
|
| 166 |
-
|
| 167 |
-
err = _ensure_llama_cpp(worker)
|
| 168 |
-
if err:
|
| 169 |
-
return err
|
| 170 |
-
try:
|
| 171 |
-
from llama_cpp import Llama
|
| 172 |
-
except Exception as e:
|
| 173 |
-
_set_stage(worker, "import FAILED", str(e))
|
| 174 |
-
return f"llama-cpp-python is not available: {e}"
|
| 175 |
-
repo, fname = MODEL_ZOO[name]
|
| 176 |
-
try:
|
| 177 |
-
_set_stage(worker, "downloading GGUF",
|
| 178 |
-
f"{repo}/{fname} (cached on /data after first time)")
|
| 179 |
-
path = hf_hub_download(repo_id=repo, filename=fname)
|
| 180 |
-
except Exception as e:
|
| 181 |
-
_set_stage(worker, "download FAILED", str(e))
|
| 182 |
-
return f"Could not download {repo}/{fname}: {e}"
|
| 183 |
-
try:
|
| 184 |
-
_set_stage(worker, "loading model into RAM", name)
|
| 185 |
-
w["llm"] = None
|
| 186 |
-
small = worker != "analyst"
|
| 187 |
-
w["llm"] = Llama(
|
| 188 |
-
model_path=path,
|
| 189 |
-
n_ctx=4096 if small else 6144,
|
| 190 |
-
n_threads=(4 if small else _NCPU), # leave headroom for parallel agents
|
| 191 |
-
n_threads_batch=(6 if small else _NCPU),
|
| 192 |
-
n_batch=512,
|
| 193 |
-
verbose=False,
|
| 194 |
-
)
|
| 195 |
-
w["model"] = name
|
| 196 |
-
_set_stage(worker, "ready", name)
|
| 197 |
-
return f"✅ {WORKER_LABEL[worker]} ready: {name}"
|
| 198 |
-
except Exception as e:
|
| 199 |
-
w["llm"] = None
|
| 200 |
-
_set_stage(worker, "load FAILED", str(e))
|
| 201 |
-
return f"Failed to load model: {e}"
|
| 202 |
-
finally:
|
| 203 |
-
w["load_lock"].release()
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
def auto_load_all():
|
| 207 |
-
"""Startup: tiny agents first (one small GGUF download serves all three),
|
| 208 |
-
then the Analyst. Runs in a background thread."""
|
| 209 |
-
for key in ("translator", "narrator", "reporter", "analyst"):
|
| 210 |
-
load_model(WORKERS[key]["model"], worker=key)
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
# ─────────────────────── status ───────────────────────
|
| 214 |
-
def is_loaded(worker: str = None) -> bool:
|
| 215 |
-
if worker:
|
| 216 |
-
return WORKERS[_wk(worker)]["llm"] is not None
|
| 217 |
-
return any(w["llm"] is not None for w in WORKERS.values())
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
def status() -> str:
|
| 221 |
-
lines = []
|
| 222 |
-
for key in ("translator", "narrator", "reporter", "analyst"):
|
| 223 |
-
w = WORKERS[key]
|
| 224 |
-
label = WORKER_LABEL[key]
|
| 225 |
-
if w["llm"] is not None:
|
| 226 |
-
lines.append(f"✅ **{label}** — {w['model']} · llama.cpp, local")
|
| 227 |
-
elif w["stage"] == "idle":
|
| 228 |
-
lines.append(f"⚪ **{label}** — not loaded yet (auto-loads at startup)")
|
| 229 |
else:
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
# ─────────────────────── inference ───────────────────────
|
| 237 |
-
DEFAULT_SYSTEM = ("You are a sub-agent of Chan Compass, a US-equity dashboard. "
|
| 238 |
-
"Answer in clear, concise English.")
|
| 239 |
-
MAX_PROMPT_CHARS = 3200
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
def _messages(user: str, system: str):
|
| 243 |
-
return [{"role": "system", "content": system + " /no_think"},
|
| 244 |
-
{"role": "user", "content": user[:MAX_PROMPT_CHARS]}]
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
|
| 248 |
-
system: str = DEFAULT_SYSTEM, worker: str = "translator") -> str:
|
| 249 |
-
"""Blocking chat on one sub-agent (used by pipeline/agent code)."""
|
| 250 |
-
w = WORKERS[_wk(worker)]; worker = _wk(worker)
|
| 251 |
-
if w["llm"] is None:
|
| 252 |
return ""
|
| 253 |
-
|
| 254 |
-
return
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
messages=_messages(user, system),
|
| 258 |
-
max_tokens=max_tokens, temperature=temperature)
|
| 259 |
-
txt = out["choices"][0]["message"]["content"] or ""
|
| 260 |
-
return _THINK_RE.sub("", txt).strip()
|
| 261 |
except Exception as e:
|
| 262 |
-
return f"
|
| 263 |
finally:
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
def
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
if not
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
"
|
| 298 |
-
import time
|
| 299 |
-
outs = []
|
| 300 |
-
for key in ("translator", "narrator", "reporter", "analyst"):
|
| 301 |
-
if WORKERS[key]["llm"] is None:
|
| 302 |
-
outs.append(f"{WORKER_LABEL[key]}: not loaded ({WORKERS[key]['stage']})")
|
| 303 |
-
continue
|
| 304 |
-
t0 = time.time()
|
| 305 |
-
out = chat("Reply with exactly: OK", max_tokens=6, temperature=0.0, worker=key)
|
| 306 |
-
outs.append(f"{WORKER_LABEL[key]}: **{out or '(no output)'}** · {time.time()-t0:.1f}s")
|
| 307 |
-
return "\n\n".join(outs)
|
|
|
|
| 1 |
"""
|
| 2 |
+
emailer.py — send a tab's AI result to any address (English UI + content).
|
| 3 |
|
| 4 |
+
Adapted from the user's send_predict_email.py: same multi-domain SMTP
|
| 5 |
+
auto-detection, plain+HTML multipart, but English subjects/labels and a generic
|
| 6 |
+
`send_result()` the Gradio tabs call.
|
| 7 |
|
| 8 |
+
Sender credentials live here (a Gmail App Password). To change the sender, edit
|
| 9 |
+
EMAIL_SENDER / EMAIL_PASSWORD. You can also override them with the environment
|
| 10 |
+
variables CHAN_EMAIL_SENDER / CHAN_EMAIL_PASSWORD on the Space (recommended:
|
| 11 |
+
store the App Password as a Space *secret* rather than in code).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
"""
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
import os
|
| 16 |
+
import json
|
| 17 |
+
import html
|
| 18 |
+
import logging
|
| 19 |
+
import smtplib
|
| 20 |
+
import urllib.request
|
| 21 |
+
import urllib.error
|
| 22 |
import re
|
| 23 |
+
from datetime import datetime
|
| 24 |
+
from email.mime.text import MIMEText
|
| 25 |
+
from email.mime.multipart import MIMEMultipart
|
| 26 |
+
from email.header import Header
|
| 27 |
+
from email.utils import formataddr
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger("chan_emailer")
|
| 30 |
+
|
| 31 |
+
# ── Transport 1 (preferred on HF): Resend HTTPS API on port 443 ──
|
| 32 |
+
# HF Spaces block outbound SMTP ports (465/587) → SMTP fails with
|
| 33 |
+
# "Network is unreachable". The Resend REST API uses plain HTTPS, which Spaces
|
| 34 |
+
# allow. Set a Space secret RESEND_API_KEY to enable it. Free tier ≈ 100/day.
|
| 35 |
+
# Until you verify your own domain, Resend only lets you send FROM
|
| 36 |
+
# "onboarding@resend.dev" — that's the default sender below.
|
| 37 |
+
RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "")
|
| 38 |
+
RESEND_FROM = os.environ.get("RESEND_FROM", "Chan Compass <onboarding@resend.dev>")
|
| 39 |
+
|
| 40 |
+
# ── Transport 2 (fallback, works off-HF): classic SMTP ──
|
| 41 |
+
EMAIL_SENDER = os.environ.get("CHAN_EMAIL_SENDER", "")
|
| 42 |
+
EMAIL_PASSWORD = os.environ.get("CHAN_EMAIL_PASSWORD", "")
|
| 43 |
+
|
| 44 |
+
SMTP_CONFIGS = {
|
| 45 |
+
"gmail.com": {"server": "smtp.gmail.com", "port": 465, "ssl": True},
|
| 46 |
+
"qq.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
|
| 47 |
+
"163.com": {"server": "smtp.163.com", "port": 465, "ssl": True},
|
| 48 |
+
"126.com": {"server": "smtp.126.com", "port": 465, "ssl": True},
|
| 49 |
+
"outlook.com": {"server": "smtp.office365.com", "port": 587, "ssl": False},
|
| 50 |
+
"hotmail.com": {"server": "smtp.office365.com", "port": 587, "ssl": False},
|
| 51 |
+
"foxmail.com": {"server": "smtp.qq.com", "port": 465, "ssl": True},
|
| 52 |
+
"sina.com": {"server": "smtp.sina.com", "port": 465, "ssl": True},
|
| 53 |
+
"aliyun.com": {"server": "smtp.aliyun.com", "port": 465, "ssl": True},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
+
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _sender_address(sender: str) -> str:
|
| 60 |
+
return formataddr((str(Header("Chan Compass", "utf-8")), sender))
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _md_to_plain(text: str) -> str:
|
| 64 |
+
"""Markdown -> readable plain text (for the text/plain MIME part and the
|
| 65 |
+
Resend `text` field). Strips #/**/| noise so text-only clients look clean."""
|
| 66 |
+
lines, out = text.split("\n"), []
|
| 67 |
+
i, n = 0, len(lines)
|
| 68 |
+
while i < n:
|
| 69 |
+
line = lines[i]
|
| 70 |
+
if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", lines[i + 1]):
|
| 71 |
+
header = [c.strip() for c in line.strip().strip("|").split("|")]
|
| 72 |
+
i += 2
|
| 73 |
+
while i < n and "|" in lines[i]:
|
| 74 |
+
cells = [c.strip() for c in lines[i].strip().strip("|").split("|")]
|
| 75 |
+
if len(header) == 2 and len(cells) == 2:
|
| 76 |
+
out.append(f" {cells[0]}: {cells[1]}")
|
| 77 |
+
else:
|
| 78 |
+
out.append(" " + " | ".join(cells))
|
| 79 |
+
i += 1
|
| 80 |
+
out.append("")
|
| 81 |
+
continue
|
| 82 |
+
m = re.match(r"^(#{1,4})\s+(.*)$", line)
|
| 83 |
+
if m:
|
| 84 |
+
title = m.group(2).strip()
|
| 85 |
+
out.append("")
|
| 86 |
+
out.append(title.upper() if len(m.group(1)) <= 2 else title)
|
| 87 |
+
out.append("-" * min(len(title), 60))
|
| 88 |
+
i += 1
|
| 89 |
+
continue
|
| 90 |
+
if line.strip() in ("---", "***", "___"):
|
| 91 |
+
out.append("-" * 40)
|
| 92 |
+
i += 1
|
| 93 |
+
continue
|
| 94 |
+
s = re.sub(r"\*\*(.+?)\*\*", r"\1", line)
|
| 95 |
+
s = re.sub(r"`(.+?)`", r"\1", s)
|
| 96 |
+
s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)", r"\1 (\2)", s)
|
| 97 |
+
s = re.sub(r"^_(.+)_$", r"\1", s.strip())
|
| 98 |
+
out.append(s)
|
| 99 |
+
i += 1
|
| 100 |
+
txt = "\n".join(out)
|
| 101 |
+
return re.sub(r"\n{3,}", "\n\n", txt).strip()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _inline_md(s: str) -> str:
|
| 105 |
+
s = html.escape(s)
|
| 106 |
+
s = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", s)
|
| 107 |
+
s = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", s)
|
| 108 |
+
s = re.sub(r"`(.+?)`", r"<code>\1</code>", s)
|
| 109 |
+
s = re.sub(r"\[(.+?)\]\((https?://[^\s)]+)\)",
|
| 110 |
+
r'<a href="\2">\1</a>', s)
|
| 111 |
+
return s
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _md_to_html(text: str) -> str:
|
| 115 |
+
"""Minimal but correct Markdown → HTML (headings, tables, lists, bold,
|
| 116 |
+
code, links). The reports are Markdown, so emailing them as preformatted
|
| 117 |
+
text showed raw `#`/`|` symbols — this renders them properly."""
|
| 118 |
+
lines = text.split("\n")
|
| 119 |
+
out, i, n = [], 0, len(lines)
|
| 120 |
+
while i < n:
|
| 121 |
+
line = lines[i]
|
| 122 |
+
# table block: a header row of pipes followed by a |---| separator
|
| 123 |
+
if "|" in line and i + 1 < n and re.match(r"^\s*\|?[\s:\-|]+\|?\s*$", lines[i + 1]):
|
| 124 |
+
header = [c.strip() for c in line.strip().strip("|").split("|")]
|
| 125 |
+
i += 2
|
| 126 |
+
rows = []
|
| 127 |
+
while i < n and "|" in lines[i]:
|
| 128 |
+
rows.append([c.strip() for c in lines[i].strip().strip("|").split("|")])
|
| 129 |
+
i += 1
|
| 130 |
+
th = "".join(f"<th style='text-align:left;padding:6px 10px;"
|
| 131 |
+
f"border-bottom:2px solid #ddd'>{_inline_md(c)}</th>" for c in header)
|
| 132 |
+
trs = ""
|
| 133 |
+
for r in rows:
|
| 134 |
+
tds = "".join(f"<td style='padding:6px 10px;border-bottom:1px solid #eee'>"
|
| 135 |
+
f"{_inline_md(c)}</td>" for c in r)
|
| 136 |
+
trs += f"<tr>{tds}</tr>"
|
| 137 |
+
out.append(f"<table style='border-collapse:collapse;margin:10px 0;"
|
| 138 |
+
f"font-size:13px'><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table>")
|
| 139 |
+
continue
|
| 140 |
+
m = re.match(r"^(#{1,4})\s+(.*)$", line)
|
| 141 |
+
if m:
|
| 142 |
+
lvl = len(m.group(1))
|
| 143 |
+
size = {1: 20, 2: 16, 3: 14, 4: 13}[lvl]
|
| 144 |
+
out.append(f"<h{lvl} style='font-size:{size}px;margin:14px 0 6px;"
|
| 145 |
+
f"color:#0265DC'>{_inline_md(m.group(2))}</h{lvl}>")
|
| 146 |
+
i += 1
|
| 147 |
+
continue
|
| 148 |
+
if re.match(r"^\s*[-*]\s+", line):
|
| 149 |
+
items = []
|
| 150 |
+
while i < n and re.match(r"^\s*[-*]\s+", lines[i]):
|
| 151 |
+
item_text = re.sub(r"^\s*[-*]\s+", "", lines[i])
|
| 152 |
+
items.append(f"<li>{_inline_md(item_text)}</li>")
|
| 153 |
+
i += 1
|
| 154 |
+
out.append(f"<ul style='margin:6px 0 6px 18px'>{''.join(items)}</ul>")
|
| 155 |
+
continue
|
| 156 |
+
if line.strip() in ("---", "***", "___"):
|
| 157 |
+
out.append("<hr style='border:none;border-top:1px solid #e3e6ea;margin:12px 0'>")
|
| 158 |
+
i += 1
|
| 159 |
+
continue
|
| 160 |
+
if line.strip() == "":
|
| 161 |
+
out.append("<div style='height:6px'></div>")
|
| 162 |
+
i += 1
|
| 163 |
+
continue
|
| 164 |
+
out.append(f"<p style='margin:4px 0'>{_inline_md(line)}</p>")
|
| 165 |
+
i += 1
|
| 166 |
+
body = "".join(out)
|
| 167 |
+
return (
|
| 168 |
+
'<html><body style="margin:0;padding:18px;background:#f6f7f9;">'
|
| 169 |
+
'<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;'
|
| 170 |
+
'font-size:14px;line-height:1.55;color:#1a1a1a;background:#ffffff;'
|
| 171 |
+
'padding:22px 26px;border-radius:12px;border:1px solid #e3e6ea;'
|
| 172 |
+
'max-width:780px;margin:0 auto;">'
|
| 173 |
+
f'{body}'
|
| 174 |
+
'<p style="font-size:11px;color:#8a8a8a;margin:16px 0 0;border-top:'
|
| 175 |
+
'1px solid #eee;padding-top:10px;">Sent by Chan Compass · educational '
|
| 176 |
+
'tool, not investment advice.</p>'
|
| 177 |
+
'</div></body></html>'
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _parse_recipients(raw: str) -> list:
|
| 182 |
+
parts = re.split(r"[,;\s]+", (raw or "").strip())
|
| 183 |
+
return [p for p in parts if _EMAIL_RE.match(p)]
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _close(server):
|
| 187 |
+
if server is not None:
|
| 188 |
try:
|
| 189 |
+
server.quit()
|
| 190 |
+
except Exception:
|
| 191 |
+
try:
|
| 192 |
+
server.close()
|
| 193 |
+
except Exception:
|
| 194 |
+
pass
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def _send_via_resend(recipients: list, subject: str, content: str) -> str:
|
| 198 |
+
"""HTTPS POST to Resend — works on HF (port 443). Returns '' on success."""
|
| 199 |
+
payload = json.dumps({
|
| 200 |
+
"from": RESEND_FROM,
|
| 201 |
+
"to": recipients,
|
| 202 |
+
"subject": subject,
|
| 203 |
+
"text": _md_to_plain(content),
|
| 204 |
+
"html": _md_to_html(content),
|
| 205 |
+
}).encode("utf-8")
|
| 206 |
+
req = urllib.request.Request(
|
| 207 |
+
"https://api.resend.com/emails", data=payload, method="POST",
|
| 208 |
+
headers={"Authorization": f"Bearer {RESEND_API_KEY}",
|
| 209 |
+
"Content-Type": "application/json",
|
| 210 |
+
# Resend/Cloudflare reject requests with no User-Agent
|
| 211 |
+
# (403, error code 1010) before they reach the API.
|
| 212 |
+
"User-Agent": "chan-compass/1.0 (+https://huggingface.co/spaces)"})
|
| 213 |
+
try:
|
| 214 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 215 |
+
body = resp.read().decode("utf-8", "ignore")
|
| 216 |
+
if '"id"' in body:
|
| 217 |
return ""
|
| 218 |
+
return f"Resend API responded without an id: {body[:200]}"
|
| 219 |
+
except urllib.error.HTTPError as e:
|
| 220 |
+
detail = e.read().decode("utf-8", "ignore")[:200]
|
| 221 |
+
return f"Resend HTTP {e.code}: {detail}"
|
| 222 |
+
except Exception as e:
|
| 223 |
+
return f"Resend request failed: {e}"
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _send_via_smtp(recipients: list, subject: str, content: str) -> str:
|
| 227 |
+
"""Classic SMTP — works locally / off-HF. Returns '' on success."""
|
| 228 |
+
if not EMAIL_SENDER or not EMAIL_PASSWORD:
|
| 229 |
+
return "SMTP sender not configured."
|
| 230 |
+
msg = MIMEMultipart("alternative")
|
| 231 |
+
msg["Subject"] = Header(subject, "utf-8")
|
| 232 |
+
msg["From"] = _sender_address(EMAIL_SENDER)
|
| 233 |
+
msg["To"] = ", ".join(recipients)
|
| 234 |
+
msg.attach(MIMEText(_md_to_plain(content), "plain", "utf-8"))
|
| 235 |
+
msg.attach(MIMEText(_md_to_html(content), "html", "utf-8"))
|
| 236 |
+
domain = EMAIL_SENDER.split("@")[-1].lower()
|
| 237 |
+
sc = SMTP_CONFIGS.get(domain, {"server": f"smtp.{domain}", "port": 465, "ssl": True})
|
| 238 |
+
server = None
|
| 239 |
try:
|
| 240 |
+
if sc["ssl"]:
|
| 241 |
+
server = smtplib.SMTP_SSL(sc["server"], sc["port"], timeout=20)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
else:
|
| 243 |
+
server = smtplib.SMTP(sc["server"], sc["port"], timeout=20)
|
| 244 |
+
server.starttls()
|
| 245 |
+
server.login(EMAIL_SENDER, EMAIL_PASSWORD)
|
| 246 |
+
server.send_message(msg)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
return ""
|
| 248 |
+
except smtplib.SMTPAuthenticationError:
|
| 249 |
+
return "SMTP authentication failed (check sender / App Password)."
|
| 250 |
+
except OSError as e:
|
| 251 |
+
return f"SMTP network error: {e}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
except Exception as e:
|
| 253 |
+
return f"SMTP failed: {e}"
|
| 254 |
finally:
|
| 255 |
+
_close(server)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def send_result(content: str, recipients_raw: str, subject_tag: str) -> str:
|
| 259 |
+
"""Send `content` to the address(es). Prefers the Resend HTTPS API (works on
|
| 260 |
+
HF Spaces); falls back to SMTP. English status string for the UI."""
|
| 261 |
+
if not content or not content.strip():
|
| 262 |
+
return "⚠️ Nothing to send yet — generate a result first."
|
| 263 |
+
if content.strip().startswith(("⏳", "🤖 _", "Run ", "Enter ", "Select ")):
|
| 264 |
+
return "⚠️ Wait for the AI result to finish, then send."
|
| 265 |
+
recipients = _parse_recipients(recipients_raw)
|
| 266 |
+
if not recipients:
|
| 267 |
+
return "⚠️ Enter a valid email address (e.g. name@example.com)."
|
| 268 |
+
|
| 269 |
+
subject = (f"Chan Compass · {subject_tag} · "
|
| 270 |
+
f"{datetime.now().strftime('%Y-%m-%d %H:%M')}")
|
| 271 |
+
|
| 272 |
+
errors = []
|
| 273 |
+
if RESEND_API_KEY:
|
| 274 |
+
err = _send_via_resend(recipients, subject, content)
|
| 275 |
+
if not err:
|
| 276 |
+
return f"✅ Sent to {', '.join(recipients)} (via Resend API)."
|
| 277 |
+
errors.append(err)
|
| 278 |
+
smtp_err = _send_via_smtp(recipients, subject, content)
|
| 279 |
+
if not smtp_err:
|
| 280 |
+
return f"✅ Sent to {', '.join(recipients)} (via SMTP)."
|
| 281 |
+
errors.append(smtp_err)
|
| 282 |
+
|
| 283 |
+
if not RESEND_API_KEY:
|
| 284 |
+
return ("❌ SMTP is blocked on Hugging Face Spaces (outbound mail ports "
|
| 285 |
+
"are closed). Fix: create a free key at resend.com and add it as "
|
| 286 |
+
"a Space secret named **RESEND_API_KEY** (then it sends over "
|
| 287 |
+
f"HTTPS). Detail: {smtp_err}")
|
| 288 |
+
return "❌ Send failed. " + " | ".join(errors)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
news_watch.py
CHANGED
|
@@ -1,189 +1,105 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
-
import
|
| 11 |
import os
|
| 12 |
-
import
|
|
|
|
|
|
|
| 13 |
|
| 14 |
import paths
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
try:
|
| 22 |
-
with open(
|
| 23 |
-
|
| 24 |
-
except
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
def save_holdings(tickers: list):
|
| 29 |
-
seen, out = set(), []
|
| 30 |
-
for t in tickers:
|
| 31 |
-
t = t.strip().upper()
|
| 32 |
-
if t and t not in seen:
|
| 33 |
-
seen.add(t)
|
| 34 |
-
out.append(t)
|
| 35 |
-
with open(HOLDINGS_FILE, "w", encoding="utf-8") as f:
|
| 36 |
-
f.write("\n".join(out))
|
| 37 |
-
return out
|
| 38 |
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
-
def
|
| 46 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
try:
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
today = _today_utc()
|
| 54 |
-
out = []
|
| 55 |
-
for it in items:
|
| 56 |
-
# yfinance has two schemas: legacy flat dict, or {'content': {...}}
|
| 57 |
-
c = it.get("content", it)
|
| 58 |
-
title = c.get("title") or ""
|
| 59 |
-
ts = it.get("providerPublishTime")
|
| 60 |
-
when = None
|
| 61 |
-
if ts:
|
| 62 |
-
when = dt.datetime.fromtimestamp(ts, dt.timezone.utc)
|
| 63 |
-
else:
|
| 64 |
-
pub = c.get("pubDate") or c.get("displayTime")
|
| 65 |
-
if pub:
|
| 66 |
try:
|
| 67 |
-
|
| 68 |
except ValueError:
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
def _llm_brief(ticker: str, items: list) -> str:
|
| 81 |
-
heads = "\n".join(f"- [{x['time']}] {x['title']} ({x['publisher']})" for x in items)
|
| 82 |
try:
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
f"You are an equity news analyst. Today's headlines for {ticker} "
|
| 88 |
-
f"(a stock the user currently HOLDS):\n{heads}\n\n"
|
| 89 |
-
"In ENGLISH ONLY, write:\n"
|
| 90 |
-
"1) **Per-headline:** one short line per headline above — what it says "
|
| 91 |
-
"and why it matters (or 'noise') for the holding;\n"
|
| 92 |
-
"2) **Net read:** POSITIVE / NEGATIVE / NEUTRAL with one sentence why;\n"
|
| 93 |
-
"3) **Action:** one concrete suggestion. ≤180 words, no disclaimers."
|
| 94 |
-
)
|
| 95 |
-
return llm_local.chat(prompt, max_tokens=420, worker="reporter")
|
| 96 |
-
except Exception:
|
| 97 |
return ""
|
|
|
|
| 98 |
|
| 99 |
|
| 100 |
-
def
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
tickers = tickers if tickers is not None else load_holdings()
|
| 106 |
-
if not tickers:
|
| 107 |
-
yield ("**No holdings configured.** Add tickers above (e.g. `AAPL, NVDA`) "
|
| 108 |
-
"and save — they'll be checked for news every day.")
|
| 109 |
-
return
|
| 110 |
-
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
| 111 |
-
out = [f"_Checking {len(tickers)} holding(s) · {stamp}_"]
|
| 112 |
-
quiet = []
|
| 113 |
-
|
| 114 |
-
def render():
|
| 115 |
-
body = "\n".join(out)
|
| 116 |
-
if quiet:
|
| 117 |
-
body += f"\n\n**Quiet today (no news):** {', '.join(quiet)}"
|
| 118 |
-
return body
|
| 119 |
-
|
| 120 |
-
for t in tickers:
|
| 121 |
-
out.append(f"\n### 📰 {t} …searching today's news")
|
| 122 |
-
yield render()
|
| 123 |
-
items = fetch_today_news(t)
|
| 124 |
-
if not items:
|
| 125 |
-
out.pop() # drop the "searching" line
|
| 126 |
-
quiet.append(t)
|
| 127 |
-
yield render()
|
| 128 |
-
continue
|
| 129 |
-
out[-1] = f"\n### 📰 {t} — {len(items)} item(s) today"
|
| 130 |
-
yield render()
|
| 131 |
-
# print each headline the moment we have it
|
| 132 |
-
for x in items[:6]:
|
| 133 |
-
link = f" · [link]({x['link']})" if x["link"] else ""
|
| 134 |
-
out.append(f"- **{x['time']}** {x['title']} — *{x['publisher']}*{link}")
|
| 135 |
-
yield render()
|
| 136 |
-
# then stream the AI brief for this ticker (Reporter sub-agent)
|
| 137 |
-
if llm_local.is_loaded("reporter") or llm_local.is_loaded("translator"):
|
| 138 |
-
wk = "reporter" if llm_local.is_loaded("reporter") else "translator"
|
| 139 |
-
heads = "\n".join(f"- [{x['time']}] {x['title']} ({x['publisher']})"
|
| 140 |
-
for x in items[:6])
|
| 141 |
-
prompt = (
|
| 142 |
-
f"You are an equity news analyst. Today's headlines for {ticker_safe(t)} "
|
| 143 |
-
f"(a stock the user HOLDS):\n{heads}\n\nIn ENGLISH ONLY:\n"
|
| 144 |
-
f"1) **Per-headline:** one short line each — what it says and why it "
|
| 145 |
-
f"matters (or 'noise');\n2) **Net read:** POSITIVE / NEGATIVE / NEUTRAL "
|
| 146 |
-
f"+ one sentence;\n3) **Action:** one concrete suggestion. ≤180 words.")
|
| 147 |
-
out.append("\n> 🤖 **Reporter sub-agent brief:** _thinking…_")
|
| 148 |
-
base = len(out) - 1
|
| 149 |
-
for acc in llm_local.chat_stream(prompt, max_tokens=420, worker=wk):
|
| 150 |
-
out[base] = "> 🤖 **Reporter sub-agent brief:**\n>\n> " + \
|
| 151 |
-
acc.replace("\n", "\n> ")
|
| 152 |
-
yield render()
|
| 153 |
-
else:
|
| 154 |
-
out.append("\n> _Model still loading — headlines shown; brief will work shortly._")
|
| 155 |
-
yield render()
|
| 156 |
-
out.append(f"\n_Done · {stamp}_")
|
| 157 |
-
yield render()
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def ticker_safe(t):
|
| 161 |
-
return str(t).upper()
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
def check_holdings_news(tickers=None) -> str:
|
| 165 |
-
"""Markdown report: AI brief per holding with today-news; quiet list otherwise."""
|
| 166 |
-
tickers = tickers if tickers is not None else load_holdings()
|
| 167 |
-
if not tickers:
|
| 168 |
-
return ("**No holdings configured.** Add tickers above (e.g. `AAPL, NVDA`) "
|
| 169 |
-
"and save — they'll be checked for news every day.")
|
| 170 |
-
blocks, quiet = [], []
|
| 171 |
-
for t in tickers:
|
| 172 |
-
items = fetch_today_news(t)
|
| 173 |
-
if not items:
|
| 174 |
-
quiet.append(t)
|
| 175 |
-
continue
|
| 176 |
-
lines = [f"### 📰 {t} — {len(items)} item(s) today"]
|
| 177 |
-
for x in items[:6]:
|
| 178 |
-
link = f" · [link]({x['link']})" if x["link"] else ""
|
| 179 |
-
lines.append(f"- **{x['time']}** {x['title']} — *{x['publisher']}*{link}")
|
| 180 |
-
brief = _llm_brief(t, items)
|
| 181 |
-
if brief:
|
| 182 |
-
lines.append(f"\n> 🤖 **AI brief:** {brief}")
|
| 183 |
-
else:
|
| 184 |
-
lines.append("\n> _Load a model in the Model tab for an AI brief._")
|
| 185 |
-
blocks.append("\n".join(lines))
|
| 186 |
-
if quiet:
|
| 187 |
-
blocks.append(f"**Quiet today (no news, ignored):** {', '.join(quiet)}")
|
| 188 |
-
stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
| 189 |
-
return f"_Checked {stamp}_\n\n" + ("\n\n---\n\n".join(blocks) if blocks else "No output.")
|
|
|
|
| 1 |
"""
|
| 2 |
+
finetune_data.py — capture (instruction, input, output) pairs from live app use,
|
| 3 |
+
then export a clean JSONL ready for LoRA SFT (the 🎯 Well-Tuned badge).
|
| 4 |
+
|
| 5 |
+
Every time the Signals "AI summary" runs, app.py calls `record()` with the
|
| 6 |
+
English raw read (input) and the model's narrative (output). Pairs accumulate
|
| 7 |
+
on the /data bucket and survive restarts; the Model tab has an "Export dataset"
|
| 8 |
+
button that writes a timestamped JSONL and reports the count.
|
| 9 |
+
|
| 10 |
+
The dataset teaches a small model ONE focused skill — turn a Chan-theory raw
|
| 11 |
+
read into a crisp long-hold trading summary — which is exactly what the app's
|
| 12 |
+
Translator sub-agent does. A 1.7B model fine-tuned on this beats a generic 4B
|
| 13 |
+
at the task, and doubles as the Tiny Titan entry.
|
| 14 |
"""
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
+
import json
|
| 18 |
import os
|
| 19 |
+
import re
|
| 20 |
+
import threading
|
| 21 |
+
import datetime as dt
|
| 22 |
|
| 23 |
import paths
|
| 24 |
|
| 25 |
+
_PAIRS = os.path.join(paths.DATASET_DIR, "pairs.jsonl")
|
| 26 |
+
_lock = threading.Lock()
|
| 27 |
+
|
| 28 |
+
INSTRUCTION = ("You are an equity analyst. Based only on this factual read of a "
|
| 29 |
+
"US stock's multi-timeframe Chan-theory verdict, write a short "
|
| 30 |
+
"plain-English summary for a long-term holder: the situation "
|
| 31 |
+
"today, whether to act or wait, and the key price levels. "
|
| 32 |
+
"Max 90 words, no disclaimers.")
|
| 33 |
+
|
| 34 |
|
| 35 |
+
def _clean(text: str) -> str:
|
| 36 |
+
# strip stray think tags and any "AI narrative ..." UI prefix
|
| 37 |
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.S)
|
| 38 |
+
text = re.sub(r"^🤖\s*\*\*AI narrative[^\n]*\*\*\s*", "", text)
|
| 39 |
+
text = text.replace("🤖 **AI narrative (Translator sub-agent · Qwen3-1.7B):**", "")
|
| 40 |
+
return text.strip()
|
| 41 |
|
| 42 |
+
|
| 43 |
+
def record(raw_read: str, narrative: str):
|
| 44 |
+
"""Append one training pair. Silently ignores junk / unloaded-model output."""
|
| 45 |
+
narrative = _clean(narrative)
|
| 46 |
+
if not raw_read or not narrative or len(narrative) < 40:
|
| 47 |
+
return
|
| 48 |
+
if narrative.startswith(("⏳", "(", "Run the analysis")):
|
| 49 |
+
return
|
| 50 |
+
row = {"instruction": INSTRUCTION, "input": raw_read.strip(), "output": narrative}
|
| 51 |
try:
|
| 52 |
+
with _lock, open(_PAIRS, "a", encoding="utf-8") as f:
|
| 53 |
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 54 |
+
except OSError:
|
| 55 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
+
def count() -> int:
|
| 59 |
+
try:
|
| 60 |
+
with open(_PAIRS, encoding="utf-8") as f:
|
| 61 |
+
return sum(1 for _ in f)
|
| 62 |
+
except OSError:
|
| 63 |
+
return 0
|
| 64 |
|
| 65 |
|
| 66 |
+
def export() -> str:
|
| 67 |
+
"""De-duplicate and write a timestamped JSONL. Returns the file path so the
|
| 68 |
+
UI can offer it as a direct download."""
|
| 69 |
+
n = count()
|
| 70 |
+
if n == 0:
|
| 71 |
+
return ""
|
| 72 |
+
seen, rows = set(), []
|
| 73 |
try:
|
| 74 |
+
with open(_PAIRS, encoding="utf-8") as f:
|
| 75 |
+
for line in f:
|
| 76 |
+
line = line.strip()
|
| 77 |
+
if not line:
|
| 78 |
+
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
try:
|
| 80 |
+
r = json.loads(line)
|
| 81 |
except ValueError:
|
| 82 |
+
continue
|
| 83 |
+
key = (r.get("input", ""), r.get("output", ""))
|
| 84 |
+
if key in seen:
|
| 85 |
+
continue
|
| 86 |
+
seen.add(key)
|
| 87 |
+
rows.append(r)
|
| 88 |
+
except OSError:
|
| 89 |
+
return ""
|
| 90 |
+
stamp = dt.datetime.utcnow().strftime("%Y%m%d-%H%M%S")
|
| 91 |
+
out = os.path.join(paths.DATASET_DIR, f"chan_sft_{stamp}.jsonl")
|
|
|
|
|
|
|
|
|
|
| 92 |
try:
|
| 93 |
+
with open(out, "w", encoding="utf-8") as f:
|
| 94 |
+
for r in rows:
|
| 95 |
+
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
| 96 |
+
except OSError:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
return ""
|
| 98 |
+
return out
|
| 99 |
|
| 100 |
|
| 101 |
+
def status_line() -> str:
|
| 102 |
+
n = count()
|
| 103 |
+
if n == 0:
|
| 104 |
+
return "_No fine-tuning pairs captured yet — run a few Signals AI summaries._"
|
| 105 |
+
return f"📚 **{n}** training pair(s) captured on /data (target: 200-500 for a good LoRA)."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
paths.py
CHANGED
|
@@ -1,64 +1,307 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
/data/pylibs llama-cpp-python installed once at runtime, persisted
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
|
|
|
| 16 |
"""
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
import os
|
| 20 |
-
import
|
|
|
|
| 21 |
|
|
|
|
|
|
|
| 22 |
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
try:
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
-
CACHE_DIR = os.path.join(DATA_ROOT, "cache_us")
|
| 34 |
-
OUTPUT_DIR = os.path.join(DATA_ROOT, "output") if PERSISTENT else "./_app_output"
|
| 35 |
-
REPORTS_DIR = os.path.join(DATA_ROOT, "reports")
|
| 36 |
-
TRACES_DIR = os.path.join(DATA_ROOT, "traces")
|
| 37 |
-
DATASET_DIR = os.path.join(DATA_ROOT, "dataset") # SFT training pairs (JSONL)
|
| 38 |
-
HF_CACHE_DIR = os.path.join(DATA_ROOT, "hf_cache")
|
| 39 |
-
PYLIBS_DIR = os.path.join(DATA_ROOT, "pylibs")
|
| 40 |
|
| 41 |
-
|
| 42 |
try:
|
| 43 |
-
|
| 44 |
-
|
|
|
|
| 45 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
# GGUF downloads (hf_hub_download) go to the bucket so models persist
|
| 48 |
-
if PERSISTENT:
|
| 49 |
-
os.environ.setdefault("HF_HOME", HF_CACHE_DIR)
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
-
def
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
llm_local.py — local sub-agent pool (llama.cpp runtime, no cloud APIs).
|
| 3 |
|
| 4 |
+
Two independent model instances ("sub-agents"), each with its own lock, so
|
| 5 |
+
features never block each other with "model busy":
|
| 6 |
|
| 7 |
+
fast · Summary Sub-Agent (Chan-Tuned Qwen3-1.7B)
|
| 8 |
+
→ Explain-in-English, sector-rotation narrative, news briefs.
|
| 9 |
+
Small = quick CPU prefill, answers start streaming in seconds.
|
| 10 |
+
deep · Analyst — Qwen3-4B Q4_K_M by default (swappable in the Model tab)
|
| 11 |
+
→ the multi-step Auto Research agent's report writing.
|
|
|
|
| 12 |
|
| 13 |
+
Both run through llama.cpp (llama-cpp-python) and are far below the 32B cap;
|
| 14 |
+
the fast worker doubles as the "Tiny Titan" (≤4B) story. ~5 GB RAM total on a
|
| 15 |
+
32 GB Space. Earns "Off the Grid" + "Llama Champion".
|
| 16 |
"""
|
| 17 |
from __future__ import annotations
|
| 18 |
|
| 19 |
import os
|
| 20 |
+
import re
|
| 21 |
+
import threading
|
| 22 |
|
| 23 |
+
import paths # sets HF_HOME + sys.path for /data persistence
|
| 24 |
+
from huggingface_hub import hf_hub_download
|
| 25 |
|
| 26 |
+
# name -> (HF repo, gguf filename)
|
| 27 |
+
MODEL_ZOO = {
|
| 28 |
+
"Chan-Tuned Qwen3-1.7B · my fine-tune": (
|
| 29 |
+
"ranranrunforit/chan-compass-qwen3-1.7b-gguf", "qwen3-1.7b.Q8_0.gguf"),
|
| 30 |
+
"Qwen3-1.7B · Tiny Titan (≤4B award class)": (
|
| 31 |
+
"Qwen/Qwen3-1.7B-GGUF", "Qwen3-1.7B-Q8_0.gguf"),
|
| 32 |
+
"Qwen3-4B · default — fast + smart, still ≤4B": (
|
| 33 |
+
"Qwen/Qwen3-4B-GGUF", "Qwen3-4B-Q4_K_M.gguf"),
|
| 34 |
+
"Qwen3-8B · best balance on 8 vCPU / 32 GB": (
|
| 35 |
+
"Qwen/Qwen3-8B-GGUF", "Qwen3-8B-Q4_K_M.gguf"),
|
| 36 |
+
"Qwen3-14B · max quality (still far under 32B cap)": (
|
| 37 |
+
"Qwen/Qwen3-14B-GGUF", "Qwen3-14B-Q4_K_M.gguf"),
|
| 38 |
+
}
|
| 39 |
+
# Only the Summary sub-agent (Signals · Explain) uses the published
|
| 40 |
+
# fine-tune; every other sub-agent stays on the stock models.
|
| 41 |
+
FAST_MODEL = "Qwen3-1.7B · Tiny Titan (≤4B award class)"
|
| 42 |
+
TRANSLATOR_MODEL = "Chan-Tuned Qwen3-1.7B · my fine-tune"
|
| 43 |
+
DEFAULT_MODEL = "Qwen3-4B · default — fast + smart, still ≤4B"
|
| 44 |
+
|
| 45 |
+
_THINK_RE = re.compile(r"<think>.*?</think>", re.S)
|
| 46 |
+
_NCPU = max(2, (os.cpu_count() or 4))
|
| 47 |
+
|
| 48 |
+
# One dedicated sub-agent per feature — independent locks, so Signals-Explain,
|
| 49 |
+
# Rotation narrative, News briefs and Auto-Research never fight over a model.
|
| 50 |
+
# Three tiny 1.7B instances share ONE GGUF file on disk (~2 GB RAM each) and
|
| 51 |
+
# the 4B Analyst writes reports. Total ≈ 9 GB on a 32 GB Space.
|
| 52 |
+
WORKER_LABEL = {
|
| 53 |
+
"translator": "Summary sub-agent (Signals · Explain)",
|
| 54 |
+
"narrator": "Narrator sub-agent (Sector Rotation)",
|
| 55 |
+
"reporter": "Reporter sub-agent (News · Research support)",
|
| 56 |
+
"analyst": "Analyst sub-agent (Auto Research)",
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _mk(model):
|
| 61 |
+
return {"model": model, "llm": None, "lock": threading.Lock(),
|
| 62 |
+
"load_lock": threading.Lock(), "stage": "idle", "detail": "", "ts": None}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
WORKERS = {
|
| 66 |
+
"translator": _mk(TRANSLATOR_MODEL),
|
| 67 |
+
"narrator": _mk(FAST_MODEL),
|
| 68 |
+
"reporter": _mk(FAST_MODEL),
|
| 69 |
+
"analyst": _mk(DEFAULT_MODEL),
|
| 70 |
+
}
|
| 71 |
+
# legacy aliases
|
| 72 |
+
_ALIAS = {"fast": "translator", "deep": "analyst"}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _wk(worker: str) -> str:
|
| 76 |
+
return _ALIAS.get(worker, worker)
|
| 77 |
+
|
| 78 |
+
_install_lock = threading.Lock()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _set_stage(worker: str, stage: str, detail: str = ""):
|
| 82 |
+
import datetime as _dt
|
| 83 |
+
w = WORKERS[worker]
|
| 84 |
+
w.update(stage=stage, detail=detail[:400],
|
| 85 |
+
ts=_dt.datetime.utcnow().strftime("%H:%M:%S UTC"))
|
| 86 |
try:
|
| 87 |
+
import automation
|
| 88 |
+
automation._log(f"[{WORKER_LABEL[worker]}] {stage}: {detail[:140]}")
|
| 89 |
+
except Exception:
|
| 90 |
+
pass
|
| 91 |
|
| 92 |
|
| 93 |
+
# ─────────────────────── runtime install (once, persisted) ───────────────────────
|
| 94 |
+
# Installed at RUNTIME, not at Space build time: the HF build container has
|
| 95 |
+
# little RAM and gets OOM-killed compiling the C++ extension; the runtime
|
| 96 |
+
# container has the real hardware. Prebuilt CPU wheel first, capped-parallelism
|
| 97 |
+
# source build as fallback. Persisted to /data/pylibs.
|
| 98 |
+
_WHEEL_INDEX = "https://abetlen.github.io/llama-cpp-python/whl/cpu"
|
| 99 |
+
_LLAMA_REQ = "llama-cpp-python>=0.3.8" # >=0.3.8 → Qwen3 architecture support
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
+
def _ensure_llama_cpp(worker: str) -> str:
|
| 103 |
try:
|
| 104 |
+
import llama_cpp # noqa: F401
|
| 105 |
+
return ""
|
| 106 |
+
except ImportError:
|
| 107 |
pass
|
| 108 |
+
import subprocess
|
| 109 |
+
import sys
|
| 110 |
+
with _install_lock:
|
| 111 |
+
try: # another thread may have finished it while we waited
|
| 112 |
+
import llama_cpp # noqa: F401
|
| 113 |
+
return ""
|
| 114 |
+
except ImportError:
|
| 115 |
+
pass
|
| 116 |
+
env = dict(os.environ)
|
| 117 |
+
env["CMAKE_BUILD_PARALLEL_LEVEL"] = "4"
|
| 118 |
+
_set_stage(worker, "installing llama.cpp runtime",
|
| 119 |
+
"trying official prebuilt CPU wheel (≈1 min)…")
|
| 120 |
+
if paths.PERSISTENT:
|
| 121 |
+
base = [sys.executable, "-m", "pip", "install", "--prefer-binary",
|
| 122 |
+
"--target", paths.PYLIBS_DIR]
|
| 123 |
+
else:
|
| 124 |
+
base = [sys.executable, "-m", "pip", "install", "--user", "--prefer-binary"]
|
| 125 |
+
r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX,
|
| 126 |
+
"--only-binary", "llama-cpp-python", _LLAMA_REQ],
|
| 127 |
+
capture_output=True, text=True, env=env, timeout=600)
|
| 128 |
+
if r.returncode != 0:
|
| 129 |
+
_set_stage(worker, "installing llama.cpp runtime",
|
| 130 |
+
"no prebuilt wheel matched — compiling from source "
|
| 131 |
+
"(one-time ~10-15 min; other tabs keep working)…")
|
| 132 |
+
r = subprocess.run(base + ["--extra-index-url", _WHEEL_INDEX, _LLAMA_REQ],
|
| 133 |
+
capture_output=True, text=True, env=env, timeout=2400)
|
| 134 |
+
if r.returncode != 0:
|
| 135 |
+
err = (r.stderr or r.stdout or "")[-800:]
|
| 136 |
+
_set_stage(worker, "install FAILED", err)
|
| 137 |
+
return "Could not install llama-cpp-python at runtime:\n" + err
|
| 138 |
+
import importlib
|
| 139 |
+
import site
|
| 140 |
+
cands = [paths.PYLIBS_DIR] if paths.PERSISTENT else []
|
| 141 |
+
usp = site.getusersitepackages()
|
| 142 |
+
cands += usp if isinstance(usp, list) else [usp]
|
| 143 |
+
for p in cands:
|
| 144 |
+
if p and p not in sys.path:
|
| 145 |
+
sys.path.append(p)
|
| 146 |
+
importlib.invalidate_caches()
|
| 147 |
+
try:
|
| 148 |
+
import llama_cpp # noqa: F401
|
| 149 |
+
return ""
|
| 150 |
+
except Exception as e:
|
| 151 |
+
_set_stage(worker, "install FAILED", f"installed but import failed: {e}")
|
| 152 |
+
return f"Installed but import failed: {e}"
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
# ─────────────────────── loading ───────────────────────
|
| 156 |
+
def load_model(name: str, worker: str = "analyst") -> str:
|
| 157 |
+
worker = _wk(worker)
|
| 158 |
+
"""Load a GGUF into a worker slot. Non-blocking: if that worker is already
|
| 159 |
+
installing/loading, returns its live stage instead of hanging the click."""
|
| 160 |
+
w = WORKERS[worker]
|
| 161 |
+
if not w["load_lock"].acquire(timeout=2):
|
| 162 |
+
return (f"⏳ {WORKER_LABEL[worker]} is busy — current stage: "
|
| 163 |
+
f"**{w['stage']}** ({w['detail'] or '…'}). Press “↻ Refresh status”.")
|
| 164 |
+
try:
|
| 165 |
+
if w["llm"] is not None and w["model"] == name:
|
| 166 |
+
return f"Already loaded on {WORKER_LABEL[worker]}: {name}"
|
| 167 |
+
err = _ensure_llama_cpp(worker)
|
| 168 |
+
if err:
|
| 169 |
+
return err
|
| 170 |
+
try:
|
| 171 |
+
from llama_cpp import Llama
|
| 172 |
+
except Exception as e:
|
| 173 |
+
_set_stage(worker, "import FAILED", str(e))
|
| 174 |
+
return f"llama-cpp-python is not available: {e}"
|
| 175 |
+
repo, fname = MODEL_ZOO[name]
|
| 176 |
+
try:
|
| 177 |
+
_set_stage(worker, "downloading GGUF",
|
| 178 |
+
f"{repo}/{fname} (cached on /data after first time)")
|
| 179 |
+
path = hf_hub_download(repo_id=repo, filename=fname)
|
| 180 |
+
except Exception as e:
|
| 181 |
+
_set_stage(worker, "download FAILED", str(e))
|
| 182 |
+
return f"Could not download {repo}/{fname}: {e}"
|
| 183 |
+
try:
|
| 184 |
+
_set_stage(worker, "loading model into RAM", name)
|
| 185 |
+
w["llm"] = None
|
| 186 |
+
small = worker != "analyst"
|
| 187 |
+
w["llm"] = Llama(
|
| 188 |
+
model_path=path,
|
| 189 |
+
n_ctx=4096 if small else 6144,
|
| 190 |
+
n_threads=(4 if small else _NCPU), # leave headroom for parallel agents
|
| 191 |
+
n_threads_batch=(6 if small else _NCPU),
|
| 192 |
+
n_batch=512,
|
| 193 |
+
verbose=False,
|
| 194 |
+
)
|
| 195 |
+
w["model"] = name
|
| 196 |
+
_set_stage(worker, "ready", name)
|
| 197 |
+
return f"✅ {WORKER_LABEL[worker]} ready: {name}"
|
| 198 |
+
except Exception as e:
|
| 199 |
+
w["llm"] = None
|
| 200 |
+
_set_stage(worker, "load FAILED", str(e))
|
| 201 |
+
return f"Failed to load model: {e}"
|
| 202 |
+
finally:
|
| 203 |
+
w["load_lock"].release()
|
| 204 |
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
+
def auto_load_all():
|
| 207 |
+
"""Startup: tiny agents first (one small GGUF download serves all three),
|
| 208 |
+
then the Analyst. Runs in a background thread."""
|
| 209 |
+
for key in ("translator", "narrator", "reporter", "analyst"):
|
| 210 |
+
load_model(WORKERS[key]["model"], worker=key)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ─────────────────────── status ───────────────────────
|
| 214 |
+
def is_loaded(worker: str = None) -> bool:
|
| 215 |
+
if worker:
|
| 216 |
+
return WORKERS[_wk(worker)]["llm"] is not None
|
| 217 |
+
return any(w["llm"] is not None for w in WORKERS.values())
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def status() -> str:
|
| 221 |
+
lines = []
|
| 222 |
+
for key in ("translator", "narrator", "reporter", "analyst"):
|
| 223 |
+
w = WORKERS[key]
|
| 224 |
+
label = WORKER_LABEL[key]
|
| 225 |
+
if w["llm"] is not None:
|
| 226 |
+
lines.append(f"✅ **{label}** — {w['model']} · llama.cpp, local")
|
| 227 |
+
elif w["stage"] == "idle":
|
| 228 |
+
lines.append(f"⚪ **{label}** — not loaded yet (auto-loads at startup)")
|
| 229 |
+
else:
|
| 230 |
+
lines.append(f"⏳ **{label}** — {w['stage']} ({w['ts']}): {w['detail'] or '…'}")
|
| 231 |
+
lines.append("\n_Each sub-agent has its own lock — Explain / narrative / "
|
| 232 |
+
"research run in parallel without “model busy”._")
|
| 233 |
+
return "\n\n".join(lines)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ─────────────────────── inference ───────────────────────
|
| 237 |
+
DEFAULT_SYSTEM = ("You are a sub-agent of Chan Compass, a US-equity dashboard. "
|
| 238 |
+
"Answer in clear, concise English.")
|
| 239 |
+
MAX_PROMPT_CHARS = 3200
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _messages(user: str, system: str):
|
| 243 |
+
return [{"role": "system", "content": system + " /no_think"},
|
| 244 |
+
{"role": "user", "content": user[:MAX_PROMPT_CHARS]}]
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def chat(user: str, max_tokens: int = 500, temperature: float = 0.3,
|
| 248 |
+
system: str = DEFAULT_SYSTEM, worker: str = "translator") -> str:
|
| 249 |
+
"""Blocking chat on one sub-agent (used by pipeline/agent code)."""
|
| 250 |
+
w = WORKERS[_wk(worker)]; worker = _wk(worker)
|
| 251 |
+
if w["llm"] is None:
|
| 252 |
+
return ""
|
| 253 |
+
if not w["lock"].acquire(timeout=180):
|
| 254 |
+
return f"({WORKER_LABEL[worker]} busy — try again in a moment)"
|
| 255 |
+
try:
|
| 256 |
+
out = w["llm"].create_chat_completion(
|
| 257 |
+
messages=_messages(user, system),
|
| 258 |
+
max_tokens=max_tokens, temperature=temperature)
|
| 259 |
+
txt = out["choices"][0]["message"]["content"] or ""
|
| 260 |
+
return _THINK_RE.sub("", txt).strip()
|
| 261 |
+
except Exception as e:
|
| 262 |
+
return f"(model error: {e})"
|
| 263 |
+
finally:
|
| 264 |
+
w["lock"].release()
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def chat_stream(user: str, max_tokens: int = 500, temperature: float = 0.3,
|
| 268 |
+
system: str = DEFAULT_SYSTEM, worker: str = "translator"):
|
| 269 |
+
"""Streaming chat on one sub-agent — yields cumulative text immediately."""
|
| 270 |
+
w = WORKERS[_wk(worker)]; worker = _wk(worker)
|
| 271 |
+
if w["llm"] is None:
|
| 272 |
+
yield (f"⏳ {WORKER_LABEL[worker]} isn't ready yet — "
|
| 273 |
+
f"stage: {w['stage']}. Check the **Model** tab.")
|
| 274 |
+
return
|
| 275 |
+
if not w["lock"].acquire(timeout=5):
|
| 276 |
+
yield f"⏳ {WORKER_LABEL[worker]} is finishing another answer — try again in a few seconds."
|
| 277 |
+
return
|
| 278 |
+
try:
|
| 279 |
+
acc = ""
|
| 280 |
+
for chunk in w["llm"].create_chat_completion(
|
| 281 |
+
messages=_messages(user, system),
|
| 282 |
+
max_tokens=max_tokens, temperature=temperature, stream=True):
|
| 283 |
+
delta = chunk["choices"][0]["delta"].get("content") or ""
|
| 284 |
+
if not delta:
|
| 285 |
+
continue
|
| 286 |
+
acc += delta
|
| 287 |
+
yield _THINK_RE.sub("", acc).replace("<think>", "").strip()
|
| 288 |
+
if not acc.strip():
|
| 289 |
+
yield "(model returned no text — try again)"
|
| 290 |
+
except Exception as e:
|
| 291 |
+
yield f"(model error: {e})"
|
| 292 |
+
finally:
|
| 293 |
+
w["lock"].release()
|
| 294 |
|
| 295 |
|
| 296 |
+
def quick_test() -> str:
|
| 297 |
+
"""Sanity check both sub-agents."""
|
| 298 |
+
import time
|
| 299 |
+
outs = []
|
| 300 |
+
for key in ("translator", "narrator", "reporter", "analyst"):
|
| 301 |
+
if WORKERS[key]["llm"] is None:
|
| 302 |
+
outs.append(f"{WORKER_LABEL[key]}: not loaded ({WORKERS[key]['stage']})")
|
| 303 |
+
continue
|
| 304 |
+
t0 = time.time()
|
| 305 |
+
out = chat("Reply with exactly: OK", max_tokens=6, temperature=0.0, worker=key)
|
| 306 |
+
outs.append(f"{WORKER_LABEL[key]}: **{out or '(no output)'}** · {time.time()-t0:.1f}s")
|
| 307 |
+
return "\n\n".join(outs)
|
spacing.css
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
ELEVATION & MOTION — Chan Compass · Spectrum 2
|
| 3 |
+
Soft, low-spread drop shadows; never heavy. Spectrum easing curves.
|
| 4 |
+
============================================================ */
|
| 5 |
+
|
| 6 |
+
:root {
|
| 7 |
+
/* ---- Drop shadows (Spectrum 2 elevation) ---- */
|
| 8 |
+
--shadow-flat: none;
|
| 9 |
+
--shadow-raised: 0 1px 3px rgba(0,0,0,.06), 0 1px 1px rgba(0,0,0,.04);
|
| 10 |
+
--shadow-elevated: 0 2px 8px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
|
| 11 |
+
--shadow-emphasized:0 6px 20px rgba(0,0,0,.12), 0 2px 6px rgba(0,0,0,.08);
|
| 12 |
+
--shadow-dragged: 0 12px 32px rgba(0,0,0,.18), 0 4px 10px rgba(0,0,0,.10);
|
| 13 |
+
|
| 14 |
+
/* Accent-tinted glow (used on AI / focused panels) */
|
| 15 |
+
--shadow-accent: 0 2px 12px rgba(2,101,220,.12);
|
| 16 |
+
|
| 17 |
+
/* ---- Focus ring ---- */
|
| 18 |
+
--focus-ring-width: 2px;
|
| 19 |
+
--focus-ring-offset: 2px;
|
| 20 |
+
|
| 21 |
+
/* ---- Motion ---- */
|
| 22 |
+
--ease-default: cubic-bezier(0.45, 0, 0.4, 1); /* @kind other */
|
| 23 |
+
--ease-in: cubic-bezier(0.5, 0, 1, 1); /* @kind other */
|
| 24 |
+
--ease-out: cubic-bezier(0, 0, 0.4, 1); /* @kind other */
|
| 25 |
+
--ease-in-out: cubic-bezier(0.45, 0, 0.4, 1); /* @kind other */
|
| 26 |
+
|
| 27 |
+
--duration-fast: 130ms; /* @kind other */
|
| 28 |
+
--duration-default: 160ms; /* @kind other */
|
| 29 |
+
--duration-slow: 220ms; /* @kind other */
|
| 30 |
+
|
| 31 |
+
--transition-default: all var(--duration-default) var(--ease-default);
|
| 32 |
+
--transition-colors: color var(--duration-fast) var(--ease-default),
|
| 33 |
+
background-color var(--duration-fast) var(--ease-default),
|
| 34 |
+
border-color var(--duration-fast) var(--ease-default);
|
| 35 |
+
}
|
styles.css
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Chan Compass · Spectrum 2 Design System
|
| 2 |
+
|
| 3 |
+
A design system for **Chan Compass · US** — a multi-timeframe 缠论 (Chan theory)
|
| 4 |
+
stock-signal engine — rendered in **Adobe Spectrum 2**, Adobe's design language.
|
| 5 |
+
It exists to give design agents a single, accurate source of truth for building
|
| 6 |
+
beautiful, on-brand Chan Compass interfaces (the live Gradio app, mocks, slides,
|
| 7 |
+
marketing) without re-deriving Spectrum 2 every time.
|
| 8 |
+
|
| 9 |
+
> **Spectrum 2** is Adobe's open design system. This project recreates its
|
| 10 |
+
> visual foundations (color, type, spacing, elevation, motion) and a set of
|
| 11 |
+
> React components, then applies them to the Chan Compass product surfaces.
|
| 12 |
+
|
| 13 |
+
---
|
| 14 |
+
|
| 15 |
+
## What the product is
|
| 16 |
+
|
| 17 |
+
Chan Compass is an **educational US-equities signal tool** that runs entirely
|
| 18 |
+
locally. It has six surfaces (Gradio tabs):
|
| 19 |
+
|
| 20 |
+
| Surface | What it does |
|
| 21 |
+
|---|---|
|
| 22 |
+
| **Signals** | Runs the Chan engine over a ticker pool; returns next-session **BUY / SELL / HOLD / WAIT / WATCH** with confidence, suggested weight, stop, and a full multi-timeframe ruling chain (monthly → weekly → daily → 60m → 30m → 15m → 5m nested-interval confirmation). |
|
| 23 |
+
| **Sector Rotation** | Where capital is flowing across the 11 SPDR sector ETFs; flow proxy = change % × dollar volume, plus relative strength vs SPY, over 1 / 5 / 20 days. |
|
| 24 |
+
| **Watchlist News** | Checks **today's** news per holding; pushes an AI brief only when news exists. |
|
| 25 |
+
| **Auto Research** | A local multi-step agent: PLAN → 5 evidence tools → sectioned report, with a saved JSON trace. |
|
| 26 |
+
| **Automation** | Daily pipeline at 18:10 America/New_York. |
|
| 27 |
+
| **Model** | Loads Qwen3 GGUF weights through llama.cpp — everything runs locally, nothing leaves the machine. |
|
| 28 |
+
|
| 29 |
+
The AI is a **local sub-agent pool** (Qwen3-1.7B translator/narrator + a deeper
|
| 30 |
+
analyst), served via `llama-cpp-python`. Data comes from Yahoo Finance.
|
| 31 |
+
|
| 32 |
+
## Sources this system was built from
|
| 33 |
+
|
| 34 |
+
- **Codebase:** `chan-compass-us-v1.7/` (attached) — a Gradio app (`app.py`)
|
| 35 |
+
plus the Chan engine (`chan_engine.py`, `chan_multilevel.py`, `chan_enhance.py`),
|
| 36 |
+
signal/rotation/news/research/automation modules. The app already ships an
|
| 37 |
+
*approximation* of Spectrum 2 in Gradio CSS; this system replaces it with the
|
| 38 |
+
real thing. The product deploys as a **Hugging Face Gradio Space**.
|
| 39 |
+
- **Design language — Adobe Spectrum 2 / React Spectrum:**
|
| 40 |
+
<https://github.com/adobe/react-spectrum> (the `@react-spectrum/s2` package —
|
| 41 |
+
`style/spectrum-theme.ts`, `style/tokens.ts` — was read for exact spacing,
|
| 42 |
+
radii, easing, weights and the type scale). Token values were cross-checked
|
| 43 |
+
against <https://spectrum.adobe.com/page/color-palette/>.
|
| 44 |
+
Explore that repo to go deeper on component behavior and accessibility.
|
| 45 |
+
|
| 46 |
+
> **Deploying the live app?** See `gradio/` — a ready-to-paste Spectrum 2 Gradio
|
| 47 |
+
> theme (`theme.py` = `THEME` + `CSS`), a guaranteed-green standalone demo Space,
|
| 48 |
+
> and an HF deploy checklist (`gradio/INTEGRATE.md`).
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
## Content fundamentals
|
| 53 |
+
|
| 54 |
+
How Chan Compass writes. Match this voice in any UI copy.
|
| 55 |
+
|
| 56 |
+
- **Voice:** expert, terse, instrument-panel. Copy reads like a trading desk
|
| 57 |
+
tool, not a consumer app. Short noun phrases over sentences: *"Tomorrow's
|
| 58 |
+
plan"*, *"Where capital is flowing"*, *"Buy/sell zone"*.
|
| 59 |
+
- **Person:** mostly impersonal/imperative. Buttons are verbs — *"Run analysis"*,
|
| 60 |
+
*"Check today's news"*, *"Load model"*. When it addresses the user it's second
|
| 61 |
+
person and possessive — *"My holdings"*, *"your ticker pool"*.
|
| 62 |
+
- **Casing:** **Sentence case** everywhere — labels, buttons, headings
|
| 63 |
+
(*"Run analysis"*, not "Run Analysis"). Ticker symbols and ETF codes are
|
| 64 |
+
**UPPERCASE monospace** (NVDA, XLK). Signal verbs render UPPERCASE in pills
|
| 65 |
+
(BUY/SELL/HOLD).
|
| 66 |
+
- **Bilingual register:** the engine's internal ruling chain is **Chinese**
|
| 67 |
+
(缠论 terms: 中枢, 背驰, 区间套, 买卖点 B1/B2/B3/S1/S2/S3); user-facing summaries
|
| 68 |
+
are **English**, produced by the local translator sub-agent. Keep Chinese for
|
| 69 |
+
authentic engine output; keep English for everything the user reads first.
|
| 70 |
+
- **Numbers carry meaning, not decoration.** Every percent, confidence score,
|
| 71 |
+
weight and stop price is real signal. No vanity stats. Percentages are signed
|
| 72 |
+
(+2.43% / −3.08%) and color-coded.
|
| 73 |
+
- **Honesty & disclaimers:** always frames itself as *"educational tool — not
|
| 74 |
+
investment advice."* States its data limits plainly (*"Yahoo only keeps 7 days
|
| 75 |
+
of 1-minute bars, so the 1m level is skipped"*). No hype, no guarantees.
|
| 76 |
+
- **AI is labeled.** Anything model-generated is prefixed with the sub-agent and
|
| 77 |
+
model — *"🤖 Translator sub-agent (Qwen3-1.7B · llama.cpp)"* — and notes
|
| 78 |
+
latency honestly (*"first words in ~5–15s"*).
|
| 79 |
+
- **Emoji:** the live app uses tab emoji (📈 🔄 📰 🧪 ⏰ 🧠) and the 🧭 brand mark.
|
| 80 |
+
In the **polished design system we prefer Spectrum line icons** for UI chrome
|
| 81 |
+
and reserve emoji for the brand mark / casual chips. Don't scatter emoji into
|
| 82 |
+
data or buttons.
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## Visual foundations
|
| 87 |
+
|
| 88 |
+
The Spectrum 2 look, as applied here.
|
| 89 |
+
|
| 90 |
+
- **Color vibe:** calm, neutral, **light-first**. A near-white canvas
|
| 91 |
+
(`gray-50 #F8F8F8`) with **white cards** (`gray-25`) and crisp hairline borders
|
| 92 |
+
(`gray-100 #E6E6E6`). One disciplined accent — **Spectrum blue `#0265DC`** —
|
| 93 |
+
carries all primary action and selection. Color is otherwise **semantic only**:
|
| 94 |
+
green = positive/BUY, red = negative/SELL, blue = HOLD/accent, orange = WATCH/notice.
|
| 95 |
+
No decorative color, no rainbow dashboards.
|
| 96 |
+
- **Type:** **Source Sans 3** for UI and headings (open stand-in for the
|
| 97 |
+
proprietary Adobe Clean), **Source Serif 4** for editorial/report prose,
|
| 98 |
+
**Source Code Pro** for tickers, prices and logs. Modular scale, ratio **1.125**,
|
| 99 |
+
**14px UI base**. Headings are **bold (700)** and slightly tightened
|
| 100 |
+
(`-0.015em`); body is **regular (400)** at a spacious **1.5** line height.
|
| 101 |
+
- **Spacing:** an **8px base rhythm** (2 · 4 · 8 · 12 · 16 · 24 · 32 · 48 · 64).
|
| 102 |
+
Generous padding inside cards (24px), comfortable 24–32px gaps between regions.
|
| 103 |
+
- **Backgrounds:** **flat, layered surfaces** — no photographic imagery, no
|
| 104 |
+
full-bleed art, no repeating textures. Depth comes from Spectrum's
|
| 105 |
+
*background layering* (canvas → card → subtle well), not from shadows alone.
|
| 106 |
+
The **only gradient** is a small one on the 🧭 brand mark
|
| 107 |
+
(blue → indigo → purple), echoing the app's original hero; the rest is solid.
|
| 108 |
+
- **Corner radii:** soft but not pill-everything — **16px cards**, **8px inputs
|
| 109 |
+
& square buttons**, **4px chips**, and **fully-rounded (pill) action buttons** —
|
| 110 |
+
the single most recognizable Spectrum 2 signature here.
|
| 111 |
+
- **Elevation:** **soft, low-spread** drop shadows (`raised` = `0 1px 3px
|
| 112 |
+
rgba(0,0,0,.06)`), never heavy. Cards sit barely off the canvas. The AI panel
|
| 113 |
+
gets a faint **accent-tinted glow** instead of a darker shadow.
|
| 114 |
+
- **Borders:** 1px hairlines for structure; inputs use a **2px** border that
|
| 115 |
+
turns accent-blue on focus. Tabs sit on a 2px track and the selected tab draws
|
| 116 |
+
a 2px accent underline.
|
| 117 |
+
- **Hover / press:** Spectrum's **one-stop-darker** rule — hover bumps a button
|
| 118 |
+
one color stop darker (`accent → accent-hover`), press goes one more and adds a
|
| 119 |
+
**subtle `scale(.98)`**. Quiet/ghost controls fill with `gray-75` on hover.
|
| 120 |
+
Rows highlight to `gray-75` on hover, `blue-100` when selected.
|
| 121 |
+
- **Focus:** a **2px solid accent ring with 2px offset** on every interactive
|
| 122 |
+
element — visible, never removed.
|
| 123 |
+
- **Motion:** quick and restrained. Durations **130–220ms**; the house easing is
|
| 124 |
+
Spectrum's `cubic-bezier(0.45, 0, 0.4, 1)` (with `ease-out` for entrances).
|
| 125 |
+
Fades and one-stop color shifts; **no bounces**, one tasteful pulse for live
|
| 126 |
+
status and the running pipeline step. Respect `prefers-reduced-motion`.
|
| 127 |
+
- **Transparency / blur:** used sparingly — not a glassmorphism system. Surfaces
|
| 128 |
+
are opaque; selection tints use solid subtle fills (`blue-100`), not alpha.
|
| 129 |
+
- **Cards:** white surface, 16px radius, 1px hairline border, soft raised shadow,
|
| 130 |
+
optional header with title + subtitle + a trailing action slot.
|
| 131 |
+
|
| 132 |
+
---
|
| 133 |
+
|
| 134 |
+
## Index — what's in this project
|
| 135 |
+
|
| 136 |
+
**Foundations (root)**
|
| 137 |
+
- `styles.css` — the single entry point consumers link (`@import`s only).
|
| 138 |
+
- `tokens/` — `colors.css` · `typography.css` · `spacing.css` · `elevation.css`
|
| 139 |
+
· `fonts.css` · `base.css`. Primitives + semantic aliases.
|
| 140 |
+
- `guidelines/` — foundation specimen cards (Colors, Type, Spacing) shown in the
|
| 141 |
+
Design System tab.
|
| 142 |
+
|
| 143 |
+
**Components** (`components/<group>/` — React primitives, bundled to `window.<Namespace>`)
|
| 144 |
+
- `actions/` — **Button** (pill, accent/secondary/negative/quiet)
|
| 145 |
+
- `forms/` — **Field**, **Checkbox**, **Switch**
|
| 146 |
+
- `status/` — **Badge**, **StatusLight**, **SignalBadge** (BUY/SELL/HOLD/WAIT/WATCH)
|
| 147 |
+
- `containers/` — **Card**, **InlineAlert** (incl. the signature `ai` panel)
|
| 148 |
+
- `navigation/` — **Tabs** (quiet tabs + accent underline)
|
| 149 |
+
|
| 150 |
+
**UI kit** (`ui_kits/chan-compass/`)
|
| 151 |
+
- `index.html` — full interactive Spectrum 2 recreation of the 6-tab app
|
| 152 |
+
(Signals, Rotation, Research, News, Model). Composes the components above.
|
| 153 |
+
|
| 154 |
+
**Deploy bridge** (`gradio/`)
|
| 155 |
+
- `theme.py` (`THEME` + `CSS`), standalone demo `app.py`, `requirements.txt`,
|
| 156 |
+
`README.md` (HF Space), `INTEGRATE.md` (port + deploy checklist).
|
| 157 |
+
|
| 158 |
+
**Meta**
|
| 159 |
+
- `SKILL.md` — makes this folder usable as an Agent Skill.
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## Iconography
|
| 164 |
+
|
| 165 |
+
See the **Iconography** section below and the `assets/` README. In short: Chan
|
| 166 |
+
Compass has **no custom icon set** of its own — the live app leans on **emoji**
|
| 167 |
+
for tab affordances and the **🧭 compass** as its brand mark. Spectrum's own
|
| 168 |
+
**workflow-icons** are the canonical reference but aren't freely CDN-hosted, so
|
| 169 |
+
this system substitutes **[Lucide](https://lucide.dev)** — clean 2px line icons
|
| 170 |
+
whose weight and rounded joinery read as Spectrum-adjacent — loaded from CDN in
|
| 171 |
+
the UI kit. **Substitution flagged.** If you license Adobe's Spectrum workflow
|
| 172 |
+
icons, swap Lucide for them and the visual language tightens further.
|
| 173 |
+
|
| 174 |
+
*Educational tool — not investment advice.*
|
theme.py
CHANGED
|
@@ -1,17 +1,13 @@
|
|
| 1 |
-
# Spectrum 2 theme for Gradio — Chan Compass
|
| 2 |
# ---------------------------------------------------------------
|
| 3 |
# Single source of truth for the LIVE Gradio app's look. Mirrors the
|
| 4 |
-
# Chan Compass · Spectrum 2 design system
|
| 5 |
-
#
|
| 6 |
-
# Usage in app.py:
|
| 7 |
#
|
| 8 |
# from theme import THEME, CSS
|
| 9 |
# with gr.Blocks(title="Chan Compass · US", theme=THEME, css=CSS) as demo:
|
| 10 |
# ...
|
| 11 |
-
# demo.launch()
|
| 12 |
-
#
|
| 13 |
-
# On Gradio >= 6 pass them to launch() instead:
|
| 14 |
-
# demo.launch(theme=THEME, css=CSS)
|
| 15 |
# ---------------------------------------------------------------
|
| 16 |
import gradio as gr
|
| 17 |
|
|
@@ -24,6 +20,7 @@ GRAY_25 = "#ffffff"
|
|
| 24 |
GRAY_50 = "#f8f8f8" # canvas
|
| 25 |
GRAY_75 = "#f3f3f3"
|
| 26 |
GRAY_100 = "#e6e6e6" # hairline border
|
|
|
|
| 27 |
GRAY_300 = "#b1b1b1" # field border
|
| 28 |
GRAY_500 = "#6d6d6d" # muted text
|
| 29 |
GRAY_700 = "#292929" # body text
|
|
@@ -50,18 +47,25 @@ THEME = gr.themes.Default(
|
|
| 50 |
block_radius="16px",
|
| 51 |
block_shadow="0 1px 3px rgba(0,0,0,.06), 0 1px 1px rgba(0,0,0,.04)",
|
| 52 |
block_label_text_color=GRAY_500,
|
|
|
|
| 53 |
block_title_text_color=GRAY_800,
|
|
|
|
| 54 |
border_color_primary=GRAY_100,
|
|
|
|
|
|
|
| 55 |
# text
|
| 56 |
body_text_color=GRAY_700,
|
| 57 |
body_text_color_subdued=GRAY_500,
|
|
|
|
| 58 |
# buttons — Spectrum 2 signature pill
|
| 59 |
button_large_radius="9999px",
|
| 60 |
button_small_radius="9999px",
|
|
|
|
| 61 |
button_primary_background_fill=ACCENT,
|
| 62 |
button_primary_background_fill_hover=ACCENT_HOVER,
|
| 63 |
button_primary_text_color="#ffffff",
|
| 64 |
button_primary_border_color=ACCENT,
|
|
|
|
| 65 |
button_secondary_background_fill=GRAY_25,
|
| 66 |
button_secondary_background_fill_hover=GRAY_75,
|
| 67 |
button_secondary_border_color=GRAY_300,
|
|
@@ -70,7 +74,9 @@ THEME = gr.themes.Default(
|
|
| 70 |
input_background_fill=GRAY_25,
|
| 71 |
input_border_color=GRAY_300,
|
| 72 |
input_border_color_focus=ACCENT,
|
|
|
|
| 73 |
input_radius="8px",
|
|
|
|
| 74 |
# accents / links
|
| 75 |
color_accent_soft=ACCENT_SUBTLE,
|
| 76 |
link_text_color=ACCENT_HOVER,
|
|
@@ -80,68 +86,96 @@ THEME = gr.themes.Default(
|
|
| 80 |
# ---- Fine-grained CSS the theme object can't express ----------------------
|
| 81 |
CSS = """
|
| 82 |
:root{
|
| 83 |
-
--s2-accent:#0265dc; --s2-accent-
|
| 84 |
-
--s2-gray-50:#f8f8f8; --s2-gray-75:#f3f3f3; --s2-gray-100:#e6e6e6;
|
| 85 |
-
--s2-gray-300:#b1b1b1; --s2-gray-500:#6d6d6d; --s2-gray-700:#292929; --s2-gray-800:#1b1b1b;
|
| 86 |
--s2-positive:#007a39; --s2-negative:#d7373f; --s2-notice:#b25309;
|
| 87 |
-
--s2-radius-card:16px;
|
| 88 |
}
|
| 89 |
body,.gradio-container{ background:var(--s2-gray-50)!important; color:var(--s2-gray-700);
|
| 90 |
font-family:'Source Sans 3','Adobe Clean',system-ui,sans-serif; }
|
| 91 |
-
.gradio-container{ max-width:
|
|
|
|
| 92 |
|
| 93 |
-
/*
|
| 94 |
button.primary, button.secondary, button.lg, button.sm{
|
| 95 |
border-radius:9999px!important; font-weight:600!important; letter-spacing:0;
|
| 96 |
-
transition:background-color .13s
|
|
|
|
|
|
|
| 97 |
button.primary:active, button.secondary:active{ transform:scale(.98); }
|
| 98 |
button:focus-visible{ outline:2px solid var(--s2-accent)!important; outline-offset:2px; }
|
| 99 |
.table-wrap button, table button, .dataframe button, [class*="cell-menu"] button{
|
| 100 |
-
border-radius:6px!important; font-weight:500!important; }
|
| 101 |
|
| 102 |
-
/*
|
| 103 |
-
#s2-hero{ display:flex; align-items:center; gap:
|
| 104 |
-
background:#fff
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
| 110 |
#s2-hero h1 span{ font-weight:300; color:var(--s2-gray-500); }
|
| 111 |
-
#s2-hero p{ margin:
|
| 112 |
-
#s2-hero .chips{ margin-left:auto; display:flex; flex-wrap:wrap; gap:
|
| 113 |
#s2-hero .chips span{ background:var(--s2-gray-75); border:1px solid var(--s2-gray-100); border-radius:9999px;
|
| 114 |
-
padding:3px 11px; font-size:
|
| 115 |
|
| 116 |
-
/*
|
| 117 |
-
.tab-nav{ border-bottom:2px solid var(--s2-gray-100)!important; gap:
|
| 118 |
.tab-nav button{ border:none!important; background:transparent!important; border-radius:0!important;
|
| 119 |
-
font-size:
|
| 120 |
-
padding:
|
| 121 |
-
.tab-nav button
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
/*
|
| 125 |
-
.
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
-
/*
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
thead th{ background:var(--s2-gray-75)!important; font-weight:700!important; text-transform:uppercase;
|
| 130 |
-
letter-spacing:.04em; font-size:
|
| 131 |
tbody td{ border-color:var(--s2-gray-100)!important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
-
/*
|
| 134 |
-
#detail-log textarea{ font-family:'Source Code Pro',monospace!important; font-size:12.5px!important;
|
|
|
|
| 135 |
|
| 136 |
-
/*
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
font-size:
|
| 141 |
-
.llm-out:empty::after{ content:"AI output appears here"; color:#9aa0a6;
|
| 142 |
-
font-style:italic; }
|
| 143 |
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
"""
|
| 146 |
|
| 147 |
__all__ = ["THEME", "CSS"]
|
|
|
|
| 1 |
+
# Spectrum 2 theme for Gradio — Chan Compass (v2, refined)
|
| 2 |
# ---------------------------------------------------------------
|
| 3 |
# Single source of truth for the LIVE Gradio app's look. Mirrors the
|
| 4 |
+
# Chan Compass · Spectrum 2 design system. Drop this next to app.py.
|
|
|
|
|
|
|
| 5 |
#
|
| 6 |
# from theme import THEME, CSS
|
| 7 |
# with gr.Blocks(title="Chan Compass · US", theme=THEME, css=CSS) as demo:
|
| 8 |
# ...
|
| 9 |
+
# demo.launch() # Gradio 5
|
| 10 |
+
# # Gradio 6+: demo.launch(theme=THEME, css=CSS)
|
|
|
|
|
|
|
| 11 |
# ---------------------------------------------------------------
|
| 12 |
import gradio as gr
|
| 13 |
|
|
|
|
| 20 |
GRAY_50 = "#f8f8f8" # canvas
|
| 21 |
GRAY_75 = "#f3f3f3"
|
| 22 |
GRAY_100 = "#e6e6e6" # hairline border
|
| 23 |
+
GRAY_200 = "#d5d5d5"
|
| 24 |
GRAY_300 = "#b1b1b1" # field border
|
| 25 |
GRAY_500 = "#6d6d6d" # muted text
|
| 26 |
GRAY_700 = "#292929" # body text
|
|
|
|
| 47 |
block_radius="16px",
|
| 48 |
block_shadow="0 1px 3px rgba(0,0,0,.06), 0 1px 1px rgba(0,0,0,.04)",
|
| 49 |
block_label_text_color=GRAY_500,
|
| 50 |
+
block_label_text_weight="600",
|
| 51 |
block_title_text_color=GRAY_800,
|
| 52 |
+
block_title_text_weight="700",
|
| 53 |
border_color_primary=GRAY_100,
|
| 54 |
+
panel_background_fill=GRAY_25,
|
| 55 |
+
panel_border_color=GRAY_100,
|
| 56 |
# text
|
| 57 |
body_text_color=GRAY_700,
|
| 58 |
body_text_color_subdued=GRAY_500,
|
| 59 |
+
body_text_size="14px",
|
| 60 |
# buttons — Spectrum 2 signature pill
|
| 61 |
button_large_radius="9999px",
|
| 62 |
button_small_radius="9999px",
|
| 63 |
+
button_large_padding="10px 22px",
|
| 64 |
button_primary_background_fill=ACCENT,
|
| 65 |
button_primary_background_fill_hover=ACCENT_HOVER,
|
| 66 |
button_primary_text_color="#ffffff",
|
| 67 |
button_primary_border_color=ACCENT,
|
| 68 |
+
button_primary_shadow="none",
|
| 69 |
button_secondary_background_fill=GRAY_25,
|
| 70 |
button_secondary_background_fill_hover=GRAY_75,
|
| 71 |
button_secondary_border_color=GRAY_300,
|
|
|
|
| 74 |
input_background_fill=GRAY_25,
|
| 75 |
input_border_color=GRAY_300,
|
| 76 |
input_border_color_focus=ACCENT,
|
| 77 |
+
input_border_width="2px",
|
| 78 |
input_radius="8px",
|
| 79 |
+
input_shadow="none",
|
| 80 |
# accents / links
|
| 81 |
color_accent_soft=ACCENT_SUBTLE,
|
| 82 |
link_text_color=ACCENT_HOVER,
|
|
|
|
| 86 |
# ---- Fine-grained CSS the theme object can't express ----------------------
|
| 87 |
CSS = """
|
| 88 |
:root{
|
| 89 |
+
--s2-accent:#0265dc; --s2-accent-hover:#0054b6; --s2-accent-down:#00418f; --s2-accent-subtle:#e0f2ff;
|
| 90 |
+
--s2-gray-25:#fff; --s2-gray-50:#f8f8f8; --s2-gray-75:#f3f3f3; --s2-gray-100:#e6e6e6;
|
| 91 |
+
--s2-gray-200:#d5d5d5; --s2-gray-300:#b1b1b1; --s2-gray-500:#6d6d6d; --s2-gray-700:#292929; --s2-gray-800:#1b1b1b;
|
| 92 |
--s2-positive:#007a39; --s2-negative:#d7373f; --s2-notice:#b25309;
|
| 93 |
+
--s2-radius-card:16px; --s2-ease:cubic-bezier(.45,0,.4,1);
|
| 94 |
}
|
| 95 |
body,.gradio-container{ background:var(--s2-gray-50)!important; color:var(--s2-gray-700);
|
| 96 |
font-family:'Source Sans 3','Adobe Clean',system-ui,sans-serif; }
|
| 97 |
+
.gradio-container{ max-width:1200px!important; margin:0 auto!important; padding-top:8px!important; }
|
| 98 |
+
.gap{ gap:14px; }
|
| 99 |
|
| 100 |
+
/* ---------- Buttons : Spectrum 2 pill ---------- */
|
| 101 |
button.primary, button.secondary, button.lg, button.sm{
|
| 102 |
border-radius:9999px!important; font-weight:600!important; letter-spacing:0;
|
| 103 |
+
transition:background-color .13s var(--s2-ease), transform .12s var(--s2-ease), box-shadow .13s var(--s2-ease); }
|
| 104 |
+
button.primary{ box-shadow:0 1px 2px rgba(2,101,220,.18)!important; }
|
| 105 |
+
button.primary:hover{ box-shadow:0 2px 8px rgba(2,101,220,.22)!important; }
|
| 106 |
button.primary:active, button.secondary:active{ transform:scale(.98); }
|
| 107 |
button:focus-visible{ outline:2px solid var(--s2-accent)!important; outline-offset:2px; }
|
| 108 |
.table-wrap button, table button, .dataframe button, [class*="cell-menu"] button{
|
| 109 |
+
border-radius:6px!important; font-weight:500!important; box-shadow:none!important; }
|
| 110 |
|
| 111 |
+
/* ---------- Hero ---------- */
|
| 112 |
+
#s2-hero{ display:flex; align-items:center; gap:18px;
|
| 113 |
+
background:linear-gradient(180deg,#fff 0%,#fcfdff 100%);
|
| 114 |
+
border:1px solid var(--s2-gray-100); border-radius:20px;
|
| 115 |
+
padding:22px 26px; margin:6px 0 14px; box-shadow:0 1px 3px rgba(0,0,0,.05); }
|
| 116 |
+
#s2-hero .mark{ width:50px; height:50px; flex:none; border-radius:14px; display:grid; place-items:center;
|
| 117 |
+
background:linear-gradient(135deg,#0265dc 0%,#5258e4 58%,#7326d3 100%);
|
| 118 |
+
color:#fff; font-size:26px; box-shadow:0 4px 14px rgba(2,101,220,.22); }
|
| 119 |
+
#s2-hero h1{ margin:0; font-size:25px; font-weight:800; letter-spacing:-.015em; color:var(--s2-gray-800); }
|
| 120 |
#s2-hero h1 span{ font-weight:300; color:var(--s2-gray-500); }
|
| 121 |
+
#s2-hero p{ margin:4px 0 0; color:var(--s2-gray-500); font-size:13.5px; line-height:1.45; max-width:620px; }
|
| 122 |
+
#s2-hero .chips{ margin-left:auto; display:flex; flex-wrap:wrap; gap:6px; justify-content:flex-end; max-width:330px; }
|
| 123 |
#s2-hero .chips span{ background:var(--s2-gray-75); border:1px solid var(--s2-gray-100); border-radius:9999px;
|
| 124 |
+
padding:3px 11px; font-size:11.5px; font-weight:600; color:var(--s2-gray-700); white-space:nowrap; }
|
| 125 |
|
| 126 |
+
/* ---------- Tabs : quiet + accent underline ---------- */
|
| 127 |
+
.tab-nav{ border-bottom:2px solid var(--s2-gray-100)!important; gap:22px; margin-bottom:6px; }
|
| 128 |
.tab-nav button{ border:none!important; background:transparent!important; border-radius:0!important;
|
| 129 |
+
font-size:15.5px!important; font-weight:600!important; color:var(--s2-gray-500)!important;
|
| 130 |
+
padding:11px 2px!important; margin-bottom:-2px; transition:color .13s var(--s2-ease); }
|
| 131 |
+
.tab-nav button:hover{ color:var(--s2-gray-700)!important; }
|
| 132 |
+
.tab-nav button.selected{ color:var(--s2-accent)!important; box-shadow:inset 0 -2px 0 var(--s2-accent)!important; }
|
| 133 |
+
|
| 134 |
+
/* ---------- Cards / groups ---------- */
|
| 135 |
+
.block{ border-radius:var(--s2-radius-card)!important; }
|
| 136 |
+
.gr-group, .group{ border:1px solid var(--s2-gray-100)!important; border-radius:var(--s2-radius-card)!important;
|
| 137 |
+
background:#fff; overflow:hidden; }
|
| 138 |
|
| 139 |
+
/* control bar — the primary input row of each tab, framed as a card */
|
| 140 |
+
#s2-app .s2-bar{ background:#fff!important; border:1px solid var(--s2-gray-100)!important;
|
| 141 |
+
border-radius:14px!important; padding:14px 16px!important; box-shadow:0 1px 3px rgba(0,0,0,.05);
|
| 142 |
+
align-items:flex-end!important; }
|
| 143 |
+
#s2-app .s2-bar .block{ border:none!important; box-shadow:none!important; background:transparent!important; }
|
| 144 |
|
| 145 |
+
/* eyebrow section labels */
|
| 146 |
+
.s2-eyebrow p{ margin:14px 0 2px!important; font-size:11px!important; font-weight:700!important;
|
| 147 |
+
letter-spacing:.07em; text-transform:uppercase; color:var(--s2-gray-500)!important; }
|
| 148 |
+
.s2-help p{ color:var(--s2-gray-500)!important; font-size:13px!important; line-height:1.5; margin:2px 0 6px!important; }
|
| 149 |
+
|
| 150 |
+
/* ---------- Data tables ---------- */
|
| 151 |
+
.dataframe, table{ font-size:13.5px!important; }
|
| 152 |
thead th{ background:var(--s2-gray-75)!important; font-weight:700!important; text-transform:uppercase;
|
| 153 |
+
letter-spacing:.04em; font-size:11px!important; color:var(--s2-gray-500)!important; }
|
| 154 |
tbody td{ border-color:var(--s2-gray-100)!important; }
|
| 155 |
+
tbody tr:hover td{ background:var(--s2-gray-75)!important; }
|
| 156 |
+
|
| 157 |
+
/* ---------- AI output panel — signature accent-framed surface ---------- */
|
| 158 |
+
.llm-out{ border:1px solid var(--s2-gray-100); border-left:5px solid var(--s2-accent); border-radius:12px;
|
| 159 |
+
background:#fff; padding:16px 20px; min-height:140px; font-size:14.5px; line-height:1.6;
|
| 160 |
+
overflow:auto; box-shadow:0 2px 12px rgba(2,101,220,.07); }
|
| 161 |
+
.llm-out:empty::after{ content:"🤖 AI output appears here"; color:#9aa0a6; font-style:italic; }
|
| 162 |
+
.llm-out h1,.llm-out h2,.llm-out h3{ font-size:16px; margin:.4em 0 .3em; color:var(--s2-gray-800); }
|
| 163 |
|
| 164 |
+
/* raw-read (non-AI) markdown reads as a quiet well */
|
| 165 |
+
#detail-log textarea{ font-family:'Source Code Pro',monospace!important; font-size:12.5px!important;
|
| 166 |
+
background:var(--s2-gray-75)!important; border-radius:10px!important; }
|
| 167 |
|
| 168 |
+
/* ---------- Accordion (email rows, collapsible) ---------- */
|
| 169 |
+
.gr-accordion, .accordion{ border:1px solid var(--s2-gray-100)!important; border-radius:12px!important;
|
| 170 |
+
background:#fff; overflow:hidden; }
|
| 171 |
+
.gr-accordion > button, .accordion > button{ font-weight:600!important; color:var(--s2-gray-700)!important;
|
| 172 |
+
font-size:13.5px!important; }
|
|
|
|
|
|
|
| 173 |
|
| 174 |
+
/* ---------- Misc ---------- */
|
| 175 |
+
.s2-footnote p{ color:var(--s2-gray-500)!important; font-size:12.5px!important; }
|
| 176 |
+
hr{ border:none; border-top:1px solid var(--s2-gray-100); margin:18px 0; }
|
| 177 |
+
label span{ font-weight:600; }
|
| 178 |
+
::selection{ background:var(--s2-accent-subtle); }
|
| 179 |
"""
|
| 180 |
|
| 181 |
__all__ = ["THEME", "CSS"]
|
typography.css
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ============================================================
|
| 2 |
+
FONTS — Chan Compass · Spectrum 2
|
| 3 |
+
Adobe Clean (Spectrum's UI typeface) is proprietary, so this
|
| 4 |
+
system substitutes Adobe's own open-source families:
|
| 5 |
+
· Source Sans 3 → UI / headings (stands in for Adobe Clean)
|
| 6 |
+
· Source Serif 4 → editorial serif (stands in for Adobe Clean Serif)
|
| 7 |
+
· Source Code Pro → mono / code (Spectrum's real code font)
|
| 8 |
+
Loaded from Google Fonts. If you have licensed Adobe Clean,
|
| 9 |
+
swap the @font-face stack and the families resolve automatically.
|
| 10 |
+
============================================================ */
|
| 11 |
+
|
| 12 |
+
@import url('https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400&family=Source+Serif+4:ital,wght@0,400;0,600;0,700;1,400&family=Source+Code+Pro:wght@400;500;600&display=swap');
|