feat: 威科夫多周期选股引擎与中文图表界面
新增规则驱动的月/周/日结构识别、决策融合与交易计划,提供扫描 API、本地 K 线(成交量/MACD/吸筹区间标注)及回填调度;K 线无起始日时默认取最近 N 根。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user