feat: 威科夫多周期选股引擎与中文图表界面

新增规则驱动的月/周/日结构识别、决策融合与交易计划,提供扫描 API、本地 K 线(成交量/MACD/吸筹区间标注)及回填调度;K 线无起始日时默认取最近 N 根。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-05 14:26:46 +08:00
co-authored by Cursor
parent cc95bbb638
commit 03851d2247
35 changed files with 3861 additions and 12 deletions
+63
View File
@@ -0,0 +1,63 @@
"""Decision Engine contract tests — MTF facts must not be overwritten."""
from ashare_dp.domain.wyckoff import DecisionSignal, EngineResult, WyckoffCycle, WyckoffEvent, WyckoffPhase
from ashare_dp.wyckoff.decision import DecisionEngine
def _er(name, payload, confidence=80.0, score=80.0, reasons=None):
return EngineResult(
name=name,
confidence=confidence,
score=score,
reasons=reasons or [],
payload=payload,
)
def test_monthly_distribution_daily_spring_is_watch():
eng = DecisionEngine()
monthly = _er("Cycle", {"cycle": WyckoffCycle.DISTRIBUTION.value, "trend_score": 40}, score=40)
weekly_c = _er("Cycle", {"cycle": WyckoffCycle.ACCUMULATION.value, "trend_score": 70}, score=70)
weekly_p = _er("Phase", {"phase": WyckoffPhase.B.value, "cycle": WyckoffCycle.ACCUMULATION.value, "structure_score": 65}, score=65)
weekly_e = _er("Event", {"current_event": WyckoffEvent.ST.value, "recent_events": ["SC", "AR", "ST"]}, score=60)
daily_e = _er(
"Event",
{"current_event": WyckoffEvent.SPRING.value, "recent_events": ["SC", "AR", "ST", "Spring"], "entry_score": 92},
confidence=92,
score=92,
)
daily_s = _er("Signal", {"signal_label": "Spring", "current_event": "Spring"}, confidence=92, score=92)
out = eng.run(monthly, weekly_c, weekly_p, weekly_e, daily_e, daily_s)
# Facts preserved
assert out.payload["facts"]["monthly"]["cycle"] == WyckoffCycle.DISTRIBUTION.value
assert out.payload["m_cycle"] == WyckoffCycle.DISTRIBUTION.value
assert out.payload["d_event"] == WyckoffEvent.SPRING.value
# Decision gated
assert out.payload["decision_signal"] == DecisionSignal.WATCH.value
assert out.payload["overall_score"] <= 55.0
def test_bullish_alignment_can_strong_buy():
eng = DecisionEngine()
monthly = _er("Cycle", {"cycle": WyckoffCycle.MARKUP.value, "trend_score": 90}, score=90, confidence=90)
weekly_c = _er("Cycle", {"cycle": WyckoffCycle.ACCUMULATION.value, "trend_score": 85}, score=85, confidence=85)
weekly_p = _er(
"Phase",
{"phase": WyckoffPhase.D.value, "cycle": WyckoffCycle.ACCUMULATION.value, "structure_score": 88},
score=88,
confidence=88,
)
weekly_e = _er("Event", {"current_event": WyckoffEvent.SOS.value, "recent_events": ["SOS"]}, score=85, confidence=85)
daily_e = _er(
"Event",
{"current_event": WyckoffEvent.SPRING.value, "recent_events": ["SC", "AR", "ST", "Spring", "Test"], "entry_score": 92},
confidence=92,
score=92,
)
daily_s = _er("Signal", {"signal_label": "Spring"}, confidence=92, score=92)
out = eng.run(monthly, weekly_c, weekly_p, weekly_e, daily_e, daily_s)
assert out.payload["decision_signal"] == DecisionSignal.STRONG_BUY.value
assert out.payload["stars"] >= 4
+42
View File
@@ -0,0 +1,42 @@
"""Feature / Cycle pure-engine smoke tests (no DB)."""
from datetime import date, timedelta
from ashare_dp.domain.wyckoff import OHLCVFrame
from ashare_dp.wyckoff.cycle import CycleEngine
from ashare_dp.wyckoff.features import FeatureEngine
def _synth_uptrend(n=120) -> OHLCVFrame:
base = date(2024, 1, 1)
closes = [100 + i * 0.5 for i in range(n)]
return OHLCVFrame(
ts_code="000001.SZ",
timeframe="1d",
trade_dates=[base + timedelta(days=i) for i in range(n)],
open=closes,
high=[c * 1.01 for c in closes],
low=[c * 0.99 for c in closes],
close=closes,
volume=[1_000_000 + i * 1000 for i in range(n)],
)
def test_feature_engine_snapshot():
fe = FeatureEngine()
out = fe.run(_synth_uptrend())
assert out.name == "Feature"
assert "ma20" in out.payload
assert out.payload["bars"] == 120
assert out.confidence > 50
def test_cycle_engine_markup_on_uptrend():
fe = FeatureEngine()
ce = CycleEngine()
feat = fe.run(_synth_uptrend(150))
# Use monthly timeframe rules
feat.payload["timeframe"] = "1M"
cyc = ce.run(feat, "1M")
assert cyc.payload["cycle"] in ("Markup", "Accumulation", "Unknown", "Distribution")
assert "cycle" in cyc.payload
+94
View File
@@ -0,0 +1,94 @@
"""Plan gate + insufficient TF fallback tests."""
from datetime import date, timedelta
from ashare_dp.domain.wyckoff import DecisionSignal, EngineResult, OHLCVFrame, WyckoffCycle
from ashare_dp.wyckoff.cycle import CycleEngine
from ashare_dp.wyckoff.decision import DecisionEngine
from ashare_dp.wyckoff.features import FeatureEngine
from ashare_dp.wyckoff.plan import PlanEngine
from ashare_dp.wyckoff.pipeline import analyze_symbol
from ashare_dp.wyckoff.phase import PhaseEngine
from ashare_dp.wyckoff.event import EventEngine
from ashare_dp.wyckoff.signal import SignalEngine
def _er(name, payload, confidence=80.0, score=80.0):
return EngineResult(name=name, confidence=confidence, score=score, payload=payload)
def test_plan_no_entry_on_watch_even_if_spring_event():
plan = PlanEngine()
feat = _er("Feature", {"close": 10.0, "atr": 0.3, "swing_low": 9.0, "swing_high": 11.0, "range_high": 11.0})
decision = _er(
"Decision",
{
"decision_signal": DecisionSignal.WATCH.value,
"d_event": "Spring",
},
confidence=90,
score=50,
)
out = plan.run(feat, decision)
assert out.payload["entry"] is None
assert out.payload["stop"] is None
def test_plan_entry_on_buy():
plan = PlanEngine()
feat = _er("Feature", {"close": 10.0, "atr": 0.3, "swing_low": 9.0, "swing_high": 11.0, "range_high": 11.0})
decision = _er("Decision", {"decision_signal": DecisionSignal.BUY.value, "d_event": "Spring"})
out = plan.run(feat, decision)
assert out.payload["entry"] == 10.0
assert out.payload["stop"] is not None
def test_feature_insufficient_for_short_monthly():
fe = FeatureEngine()
base = date(2024, 1, 1)
n = 10
frame = OHLCVFrame(
ts_code="000001.SZ",
timeframe="1M",
trade_dates=[base + timedelta(days=30 * i) for i in range(n)],
open=[10.0] * n,
high=[11.0] * n,
low=[9.0] * n,
close=[10.0] * n,
volume=[1e6] * n,
)
out = fe.run(frame, "1M")
assert out.payload["insufficient"] is True
cyc = CycleEngine().run(out, "1M")
assert cyc.payload["cycle"] == WyckoffCycle.UNKNOWN.value
def test_pipeline_does_not_borrow_daily_as_monthly():
"""Daily-only data → monthly cycle Unknown, not inferred from daily."""
base = date(2024, 1, 1)
n = 120
closes = [100 + i * 0.4 for i in range(n)]
daily = OHLCVFrame(
ts_code="000001.SZ",
timeframe="1d",
trade_dates=[base + timedelta(days=i) for i in range(n)],
open=closes,
high=[c * 1.01 for c in closes],
low=[c * 0.99 for c in closes],
close=closes,
volume=[1e6] * n,
)
result = analyze_symbol(
daily,
None,
None,
feature_eng=FeatureEngine(),
cycle_eng=CycleEngine(),
phase_eng=PhaseEngine(),
event_eng=EventEngine(),
signal_eng=SignalEngine(),
decision_eng=DecisionEngine(),
plan_eng=PlanEngine(),
)
assert result["f_m"].payload.get("insufficient") is True
assert result["c_m"].payload["cycle"] == WyckoffCycle.UNKNOWN.value