diff --git a/README.md b/README.md index 9cea863..b19d18d 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Optional Training Case Engine environment variables used by `run_training_cases. - `TRAINING_INSTRUMENT_ID` - defaults to `1` - `TRAINING_TIMEFRAME` - defaults to `1m` - `TRAINING_LIMIT_PER_TYPE` - defaults to `10` -- `TRAINING_CASE_TYPES` - comma-separated list, defaults to `sweep,fvg,range` +- `TRAINING_CASE_TYPES` - comma-separated list, defaults to `sweep,fvg,range,mss_displacement` Optional replay environment variables used by `run_replay_file.py`: diff --git a/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md b/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md index 5e1ecde..4411d1c 100644 --- a/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md +++ b/docs/AI_ICT_Training_Case_Engine_开发方案_v1.md @@ -564,6 +564,24 @@ Invalidation:20 3. 低分时提示重复当前阶段。 4. 高分时提示继续下一组推荐训练。 +### M13:MSS + Displacement 高阶案例 + +完成: + +1. 新增 `mss_displacement` 训练案例类型。 +2. 从 `structure_events` 读取 MSS,并关联 `displacement_events` 形成真实行情案例。 +3. answer key 覆盖 `broken_level`、`direction`、`strength_score`、`body_ratio`、`has_fvg`、`is_valid_displacement`。 +4. 新增三项任务:标注 MSS break level、判断结构接管方向、说明 displacement 验证与失效条件。 +5. Integrated Precision 阶段优先纳入 MSS + Displacement 案例。 +6. 前端 Generate、Case Type、Submit 标注流程支持该类型。 + +验收: + +1. `run_training_cases.py` 默认生成 `sweep / fvg / range / mss_displacement`。 +2. `mss_displacement` 评分包含 `mss_level / mss_direction / displacement_validation`。 +3. 推荐系统能通过 `mss / displacement / structure_shift` 弱点定位到该案例类型。 +4. 案例仍然隐藏未来,提交前不泄露 `answer_key`。 + ## 10. 第一版验收标准 第一版完成时必须满足: @@ -580,6 +598,7 @@ Invalidation:20 10. 能从低阶到高阶形成分阶段训练路径。 11. 能从阶段路径一键开始训练 attempt。 12. 提交后能获得面向下一步训练的规则教练反馈。 +13. 高阶阶段能训练 MSS + Displacement 的结构接管判断。 ## 11. 推荐立即开始的第一步 diff --git a/docs/AI_ICT_离线自动化推进记录_2026-04-29.md b/docs/AI_ICT_离线自动化推进记录_2026-04-29.md index eac68ea..d491957 100644 --- a/docs/AI_ICT_离线自动化推进记录_2026-04-29.md +++ b/docs/AI_ICT_离线自动化推进记录_2026-04-29.md @@ -812,3 +812,48 @@ quality_gate.reasons=expectancy_below_threshold, profit_factor_below_threshold, - 文档要求的“样本统计闭环”已经从代码上打通; - 当前规则不再是“样本不足无法判断”,而是“足够样本下策略质量不合格”; - 系统没有放行到执行层,符合 `回测真实性 -> signal/session 一致性 -> 结构质量 -> 样本统计 -> 受控执行` 的主线顺序。 + +## Training M13:MSS + Displacement 高阶案例(2026-04-30) + +本轮目标: + +- 把 Integrated Precision 从综合复盘入口推进到可训练 MSS 结构接管。 +- 让训练案例继续使用真实事件表,不做手工静态图例。 + +实现内容: + +- `TrainingCaseGenerator` 支持 `mss_displacement`。 +- `TrainingCaseRepository.fetch_mss_displacement_sources()` 从 `structure_events` 读取 MSS,并左关联 `displacement_events`。 +- answer key 记录被打破结构价位、方向、位移强度、实体比例、是否产生 FVG、位移是否有效。 +- 新增任务:`mark_mss_break_level`、`classify_mss_direction`、`validate_displacement`。 +- `grade_annotations()` 新增 `mss_level`、`mss_direction`、`displacement_validation` 三项评分。 +- Training Lab 的 Case Type、Generate、Submit 标注流程支持 `MSS + Displacement`。 + +自动化验收命令: + +```text +python3 -m unittest tests/unit/test_training_module.py tests/unit/test_runtime_script_json.py tests/unit/test_api_static_server.py +python3 -m py_compile src/api/training.py src/api/server.py src/services/training/*.py scripts/run_training_cases.py +node --check src/web/training/training.js +python3 -m unittest discover -s tests/unit -p "test_*.py" +python3 scripts/run_migration_static_checks.py +python3 scripts/run_script_static_checks.py +TRAINING_CASES_OUTPUT_JSON=1 TRAINING_CASE_TYPES=mss_displacement TRAINING_LIMIT_PER_TYPE=5 python3 scripts/run_training_cases.py +``` + +本轮真实库结果: + +```text +generated=4 +by_type.mss_displacement=4 +http_case_list.answer_key=null +http_submit.categories=mss_level,mss_direction,displacement_validation +http_submit.total_score=60/60 +browser.case_type=MSS + Displacement +browser.visible_cases=4 +``` + +边界约束: + +- Training 继续只读消费 `structure_events / displacement_events`。 +- Training 标注、评分、进度不写回 `trade_signals`、`trade_executions`、回测或实盘链路。 diff --git a/scripts/run_training_cases.py b/scripts/run_training_cases.py index 8ef5fcd..5589f20 100644 --- a/scripts/run_training_cases.py +++ b/scripts/run_training_cases.py @@ -21,7 +21,7 @@ def run_training_generation( limit_per_type: int = 10, output_json: bool = False, ) -> int: - case_type_list = list(case_types) if case_types is not None else ["sweep", "fvg", "range"] + case_type_list = list(case_types) if case_types is not None else ["sweep", "fvg", "range", "mss_displacement"] result = generator.generate_cases( instrument_id=instrument_id, timeframe=timeframe, @@ -68,7 +68,7 @@ def main(env=None) -> int: "instrument_id": instrument_id, "timeframe": timeframe, "limit_per_type": limit_per_type, - "case_types": case_types or ["sweep", "fvg", "range"], + "case_types": case_types or ["sweep", "fvg", "range", "mss_displacement"], "generated": 0, "by_type": {}, "case_ids": [], diff --git a/src/api/training.py b/src/api/training.py index abe1e8f..a146134 100644 --- a/src/api/training.py +++ b/src/api/training.py @@ -26,9 +26,12 @@ CASE_TYPES_BY_SKILL_TAG = { "liquidity": "sweep", "sweep": "sweep", "confirmation": "sweep", + "mss": "mss_displacement", + "displacement": "mss_displacement", + "structure_shift": "mss_displacement", } -DEFAULT_RECOMMENDATION_CASE_TYPES = ("sweep", "fvg", "range") +DEFAULT_RECOMMENDATION_CASE_TYPES = ("sweep", "fvg", "range", "mss_displacement") class TrainingAPI: @@ -83,7 +86,7 @@ class TrainingAPI: result = self.generator.generate_cases( instrument_id=instrument_id, timeframe=timeframe, - case_types=case_types or ("sweep", "fvg", "range"), + case_types=case_types or ("sweep", "fvg", "range", "mss_displacement"), limit_per_type=limit_per_type, ) return result.__dict__ diff --git a/src/services/training/annotation_service.py b/src/services/training/annotation_service.py index 766aee4..e83d6dc 100644 --- a/src/services/training/annotation_service.py +++ b/src/services/training/annotation_service.py @@ -64,6 +64,8 @@ def validate_annotation_payload(annotation_type: str, payload: dict) -> Annotati missing = [key for key in ("price_low", "price_high") if key not in payload] if missing: return AnnotationDecision(accepted=False, reason=f"missing_required_fields:{','.join(missing)}") + if annotation_type == "mss" and "price" not in payload and "value" not in payload: + return AnnotationDecision(accepted=False, reason="missing_mss_break_level") if annotation_type == "invalidation" and "price" not in payload and "condition" not in payload: return AnnotationDecision(accepted=False, reason="missing_invalidation_boundary") return AnnotationDecision(accepted=True, reason="valid") diff --git a/src/services/training/case_generator.py b/src/services/training/case_generator.py index f13d950..702a714 100644 --- a/src/services/training/case_generator.py +++ b/src/services/training/case_generator.py @@ -10,7 +10,7 @@ from src.services.training.case_repository import ( ) -SUPPORTED_CASE_TYPES = ("sweep", "fvg", "range") +SUPPORTED_CASE_TYPES = ("sweep", "fvg", "range", "mss_displacement") @dataclass(frozen=True) @@ -48,6 +48,8 @@ class TrainingCaseGenerator: current_ids = self.generate_fvg_cases(instrument_id=instrument_id, timeframe=timeframe, limit=limit_per_type) elif case_type == "range": current_ids = self.generate_range_cases(instrument_id=instrument_id, timeframe=timeframe, limit=limit_per_type) + elif case_type == "mss_displacement": + current_ids = self.generate_mss_displacement_cases(instrument_id=instrument_id, timeframe=timeframe, limit=limit_per_type) else: raise ValueError(f"unsupported_training_case_type:{case_type}") by_type[case_type] = len(current_ids) @@ -85,6 +87,38 @@ class TrainingCaseGenerator: case_ids.append(case_id) return case_ids + def generate_mss_displacement_cases(self, instrument_id: int, timeframe: str, limit: int = 10) -> list[int]: + case_ids: list[int] = [] + for source in self.repository.fetch_mss_displacement_sources(instrument_id=instrument_id, timeframe=timeframe, limit=limit): + window = build_case_window( + timeframe=timeframe, + decision_ts=source["structure_ts"], + source_start_ts=source.get("displacement_start_ts"), + source_end_ts=source.get("displacement_end_ts"), + lookback_bars=50, + forward_bars=60, + ) + record = TrainingCaseRecord( + instrument_id=instrument_id, + timeframe=timeframe, + case_type="mss_displacement", + chapter_ref="chapter_13", + skill_tags=["mss", "displacement", "structure_shift", "invalidation"], + source_event_type="structure_event", + source_event_id=source["id"], + window_start_ts=window.window_start_ts, + decision_ts=window.decision_ts, + window_end_ts=window.window_end_ts, + difficulty=classify_mss_displacement_difficulty(source), + answer_key=build_mss_displacement_answer_key(source), + status="active", + meta={"generator": "training_case_generator_v1"}, + ) + case_id = self.repository.insert_case(record) + self.repository.replace_tasks(case_id, build_mss_displacement_tasks(case_id)) + case_ids.append(case_id) + return case_ids + def generate_fvg_cases(self, instrument_id: int, timeframe: str, limit: int = 10) -> list[int]: case_ids: list[int] = [] for source in self.repository.fetch_fvg_sources(instrument_id=instrument_id, timeframe=timeframe, limit=limit): @@ -230,6 +264,26 @@ def build_range_answer_key(source: dict) -> dict: } +def build_mss_displacement_answer_key(source: dict) -> dict: + return { + "case_type": "mss_displacement", + "event_type": source.get("event_type"), + "direction": source.get("direction"), + "broken_level": _to_float(source.get("broken_level")), + "reference_swing_id": source.get("reference_swing_id"), + "displacement_event_id": source.get("displacement_event_id"), + "accepted": bool(source.get("accepted")), + "signal_relevant": bool(source.get("signal_relevant", True)), + "structure_ts": source.get("structure_ts"), + "displacement_start_ts": source.get("displacement_start_ts"), + "displacement_end_ts": source.get("displacement_end_ts"), + "strength_score": _to_float(source.get("strength_score")), + "body_ratio": _to_float(source.get("body_ratio")), + "has_fvg": bool(source.get("has_fvg")), + "is_valid_displacement": bool(source.get("is_valid_displacement", True)), + } + + def build_sweep_tasks(case_id: int) -> list[TrainingCaseTaskRecord]: return [ TrainingCaseTaskRecord( @@ -317,6 +371,35 @@ def build_range_tasks(case_id: int) -> list[TrainingCaseTaskRecord]: ] +def build_mss_displacement_tasks(case_id: int) -> list[TrainingCaseTaskRecord]: + return [ + TrainingCaseTaskRecord( + case_id=case_id, + task_type="mark_mss_break_level", + prompt="标注 MSS 打破的关键结构价位。", + required_annotation_types=["mss"], + max_score=20, + sort_order=1, + ), + TrainingCaseTaskRecord( + case_id=case_id, + task_type="classify_mss_direction", + prompt="判断本次结构接管方向是 bullish 还是 bearish。", + required_annotation_types=["multiple_choice"], + max_score=20, + sort_order=2, + ), + TrainingCaseTaskRecord( + case_id=case_id, + task_type="validate_displacement", + prompt="说明该 displacement 是否足以支持 MSS 叙事,以及失效条件是什么。", + required_annotation_types=["invalidation"], + max_score=20, + sort_order=3, + ), + ] + + def classify_sweep_difficulty(source: dict) -> str: if source.get("close_back_inside") and source.get("validation_level") == "validated": return "standard" @@ -335,6 +418,12 @@ def classify_range_difficulty(source: dict) -> str: return "advanced" +def classify_mss_displacement_difficulty(source: dict) -> str: + if source.get("accepted") and source.get("is_valid_displacement") and source.get("has_fvg"): + return "standard" + return "advanced" + + def _to_float(value) -> Optional[float]: if value is None: return None diff --git a/src/services/training/case_repository.py b/src/services/training/case_repository.py index 31a979a..bf776b4 100644 --- a/src/services/training/case_repository.py +++ b/src/services/training/case_repository.py @@ -163,6 +163,40 @@ class TrainingCaseRepository: rows = session.execute(sql, params).mappings().all() return [dict(row) for row in rows] + def fetch_mss_displacement_sources(self, instrument_id: int, timeframe: str, limit: int = 10) -> list[dict]: + sql = text( + """ + select se.id, + se.event_type, + se.direction, + se.broken_level, + se.reference_swing_id, + se.displacement_event_id, + se.accepted, + se.signal_relevant, + se.ts as structure_ts, + de.start_ts as displacement_start_ts, + de.end_ts as displacement_end_ts, + de.strength_score, + de.body_ratio, + de.has_fvg, + de.is_valid as is_valid_displacement + from structure_events se + left join displacement_events de on de.id = se.displacement_event_id + where se.instrument_id = :instrument_id + and se.timeframe = :timeframe + and se.event_type = 'MSS' + order by se.ts desc, se.id desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute( + sql, + {"instrument_id": instrument_id, "timeframe": timeframe, "limit": limit}, + ).mappings().all() + return [dict(row) for row in rows] + def insert_task(self, record: TrainingCaseTaskRecord) -> int: sql = text( """ diff --git a/src/services/training/coach_service.py b/src/services/training/coach_service.py index 2b6ba57..db0c9a9 100644 --- a/src/services/training/coach_service.py +++ b/src/services/training/coach_service.py @@ -6,6 +6,9 @@ CATEGORY_ACTIONS = { "sweep_confirmation": "Wait for sweep confirmation instead of reacting to the wick alone.", "fvg_zone": "Mark the full imbalance body and avoid shrinking the FVG too aggressively.", "fvg_state": "Classify whether the FVG is still open, touched, mitigated, or invalidated.", + "mss_level": "Re-mark the broken structure level before deciding that MSS occurred.", + "mss_direction": "Separate bullish and bearish structure shifts before judging continuation.", + "displacement_validation": "Explain why the displacement confirms the structure shift and where it fails.", "invalidation": "State the invalidation condition before any trade idea is allowed.", } diff --git a/src/services/training/curriculum_service.py b/src/services/training/curriculum_service.py index d27d686..8892e79 100644 --- a/src/services/training/curriculum_service.py +++ b/src/services/training/curriculum_service.py @@ -50,8 +50,8 @@ CURRICULUM_STAGES = [ stage_id="integrated_precision", title="Integrated Precision", level="high", - case_types=["range", "sweep", "fvg"], - skill_tags=["dealing_range", "liquidity", "sweep", "pd_array", "fvg", "invalidation"], + case_types=["mss_displacement", "range", "sweep", "fvg"], + skill_tags=["mss", "displacement", "structure_shift", "dealing_range", "liquidity", "sweep", "pd_array", "fvg", "invalidation"], required_attempts=6, required_avg_score_pct=78.0, prerequisites=["liquidity_sweep", "pd_array_fvg"], diff --git a/src/services/training/grading_service.py b/src/services/training/grading_service.py index 1e189cd..78455bc 100644 --- a/src/services/training/grading_service.py +++ b/src/services/training/grading_service.py @@ -62,6 +62,8 @@ def grade_annotations(case: dict, annotations: list[dict]) -> list[ScoreItem]: return grade_fvg(answer=answer, annotations=annotations) if case_type == "range": return grade_range(answer=answer, annotations=annotations) + if case_type == "mss_displacement": + return grade_mss_displacement(answer=answer, annotations=annotations) return [ ScoreItem( category="unsupported_case_type", @@ -157,6 +159,32 @@ def grade_range(answer: dict, annotations: list[dict]) -> list[ScoreItem]: ] +def grade_mss_displacement(answer: dict, annotations: list[dict]) -> list[ScoreItem]: + mss_annotation = find_annotation(annotations, "mss") + direction = find_annotation(annotations, "multiple_choice") + invalidation = find_annotation(annotations, "invalidation") + return [ + score_level( + category="mss_level", + annotation=mss_annotation, + answer_price=answer.get("broken_level"), + max_score=20, + ), + score_choice( + category="mss_direction", + annotation=direction, + accepted_values=[answer.get("direction")], + max_score=20, + ), + score_presence( + category="displacement_validation", + annotation=invalidation, + required_any=("price", "condition", "text"), + max_score=20, + ), + ] + + def find_annotation(annotations: list[dict], annotation_type: str) -> Optional[dict]: for annotation in reversed(annotations): if annotation.get("annotation_type") == annotation_type: diff --git a/src/web/training/index.html b/src/web/training/index.html index 026d650..27828b0 100644 --- a/src/web/training/index.html +++ b/src/web/training/index.html @@ -23,6 +23,7 @@ + diff --git a/src/web/training/training.js b/src/web/training/training.js index b0cc211..b71b57c 100644 --- a/src/web/training/training.js +++ b/src/web/training/training.js @@ -91,7 +91,7 @@ async function generateCases() { const payload = await postJson('/training/cases/generate', { instrument_id: Number(el.instrumentId.value || 1), timeframe: '1m', - case_types: el.caseType.value ? [el.caseType.value] : ['sweep', 'fvg', 'range'], + case_types: el.caseType.value ? [el.caseType.value] : ['sweep', 'fvg', 'range', 'mss_displacement'], limit_per_type: Math.min(Number(el.caseLimit.value || 10), 10), }); setStatus(`Generated: ${payload.generated}`); @@ -339,6 +339,11 @@ async function submitAnnotations() { await maybePostAnnotation('multiple_choice', { value: el.choiceValue.value.trim() }); await maybePostAnnotation('invalidation', invalidationPayload()); } + if (caseType === 'mss_displacement') { + await maybePostAnnotation('mss', { price: numberOrNull(el.levelPrice.value), direction: el.choiceValue.value.trim() }); + await maybePostAnnotation('multiple_choice', { value: el.choiceValue.value.trim() }); + await maybePostAnnotation('invalidation', invalidationPayload()); + } } async function maybePostAnnotation(annotationType, payload) { diff --git a/tests/unit/test_runtime_script_json.py b/tests/unit/test_runtime_script_json.py index 7f6dca4..8eb2283 100644 --- a/tests/unit/test_runtime_script_json.py +++ b/tests/unit/test_runtime_script_json.py @@ -106,6 +106,7 @@ class RuntimeScriptJsonTests(unittest.TestCase): self.assertEqual(payload["error_type"], "OperationalError") self.assertEqual(payload["instrument_id"], 1) self.assertEqual(payload["timeframe"], "1m") + self.assertEqual(payload["case_types"], ["sweep", "fvg", "range", "mss_displacement"]) self.assertEqual(payload["generated"], 0) def test_training_cases_outputs_generation_json(self) -> None: diff --git a/tests/unit/test_training_module.py b/tests/unit/test_training_module.py index 6bd085d..ca1007e 100644 --- a/tests/unit/test_training_module.py +++ b/tests/unit/test_training_module.py @@ -15,6 +15,7 @@ from src.services.training.case_repository import _case_params, _list_cases_quer from src.services.training.case_generator import ( build_case_window, build_fvg_answer_key, + build_mss_displacement_answer_key, build_range_answer_key, build_sweep_answer_key, timeframe_to_timedelta, @@ -101,6 +102,12 @@ class TrainingAnnotationServiceTests(unittest.TestCase): self.assertFalse(decision.accepted) self.assertEqual(decision.reason, "missing_required_fields:price_high") + def test_requires_price_for_mss_annotations(self) -> None: + decision = validate_annotation_payload("mss", {"direction": "bullish"}) + + self.assertFalse(decision.accepted) + self.assertEqual(decision.reason, "missing_mss_break_level") + def test_accepts_valid_annotation_and_persists_it(self) -> None: repo = StubTrainingRepository() service = AnnotationService(repository=repo) @@ -198,27 +205,49 @@ class TrainingCaseGeneratorTests(unittest.TestCase): "ts_end": None, } ) + mss_key = build_mss_displacement_answer_key( + { + "event_type": "MSS", + "direction": "bullish", + "broken_level": Decimal("111.5"), + "reference_swing_id": 4, + "displacement_event_id": 9, + "accepted": True, + "signal_relevant": True, + "structure_ts": ts, + "displacement_start_ts": ts, + "displacement_end_ts": ts, + "strength_score": Decimal("1.25"), + "body_ratio": Decimal("0.72"), + "has_fvg": True, + "is_valid_displacement": True, + } + ) self.assertEqual(sweep["pool_price_high"], 100.2) self.assertEqual(fvg["fill_pct"], 0.25) self.assertEqual(range_key["equilibrium"], 110.0) + self.assertEqual(mss_key["broken_level"], 111.5) + self.assertEqual(mss_key["strength_score"], 1.25) - def test_generate_cases_creates_three_case_types_and_tasks(self) -> None: + def test_generate_cases_creates_four_case_types_and_tasks(self) -> None: repo = StubCaseGenerationRepository() generator = TrainingCaseGenerator(repository=repo) result = generator.generate_cases(instrument_id=1, timeframe="1m", limit_per_type=2) - self.assertEqual(result.generated, 3) - self.assertEqual(result.by_type, {"sweep": 1, "fvg": 1, "range": 1}) - self.assertEqual(len(repo.cases), 3) + self.assertEqual(result.generated, 4) + self.assertEqual(result.by_type, {"sweep": 1, "fvg": 1, "range": 1, "mss_displacement": 1}) + self.assertEqual(len(repo.cases), 4) self.assertEqual(repo.cases[0].case_type, "sweep") self.assertEqual(repo.cases[0].chapter_ref, "chapter_10") self.assertEqual(repo.cases[1].case_type, "fvg") self.assertEqual(repo.cases[2].case_type, "range") + self.assertEqual(repo.cases[3].case_type, "mss_displacement") self.assertEqual(len(repo.replaced_tasks[1001]), 3) self.assertEqual(repo.replaced_tasks[1002][0].task_type, "mark_fvg_zone") self.assertEqual(repo.replaced_tasks[1003][0].task_type, "mark_dealing_range") + self.assertEqual(repo.replaced_tasks[1004][0].task_type, "mark_mss_break_level") def test_rejects_unknown_case_type(self) -> None: generator = TrainingCaseGenerator(repository=StubCaseGenerationRepository()) @@ -292,6 +321,22 @@ class TrainingGradingServiceTests(unittest.TestCase): self.assertEqual(sum(item.score for item in scores), 40) self.assertEqual(scores[-1].feedback["reason"], "missing_annotation") + def test_grade_mss_displacement_scores_level_direction_and_validation(self) -> None: + case = { + "case_type": "mss_displacement", + "answer_key": {"broken_level": 111.5, "direction": "bullish"}, + } + annotations = [ + {"annotation_type": "mss", "payload": {"price": 111.5}}, + {"annotation_type": "multiple_choice", "payload": {"value": "bullish"}}, + {"annotation_type": "invalidation", "payload": {"condition": "close back below broken level"}}, + ] + + scores = grade_annotations(case=case, annotations=annotations) + + self.assertEqual([item.category for item in scores], ["mss_level", "mss_direction", "displacement_validation"]) + self.assertEqual(sum(item.score for item in scores), 60) + def test_grading_service_persists_scores(self) -> None: repo = StubGradingRepository() service = TrainingGradingService(repository=repo) @@ -674,6 +719,27 @@ class StubCaseGenerationRepository: } ] + def fetch_mss_displacement_sources(self, **kwargs): + return [ + { + "id": 41, + "event_type": "MSS", + "direction": "bullish", + "broken_level": Decimal("111.5"), + "reference_swing_id": 4, + "displacement_event_id": 9, + "accepted": True, + "signal_relevant": True, + "structure_ts": self.ts, + "displacement_start_ts": self.ts, + "displacement_end_ts": self.ts, + "strength_score": Decimal("1.25"), + "body_ratio": Decimal("0.72"), + "has_fvg": True, + "is_valid_displacement": True, + } + ] + class StubReplayRepository: def __init__(self) -> None: