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: