feat: add mss displacement training cases

This commit is contained in:
yuqei
2026-04-30 00:29:18 +08:00
parent 92f6b34263
commit 8377bd06fa
15 changed files with 309 additions and 13 deletions
+1 -1
View File
@@ -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`:
@@ -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. 推荐立即开始的第一步
@@ -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`、回测或实盘链路。
+2 -2
View File
@@ -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": [],
+5 -2
View File
@@ -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__
@@ -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")
+90 -1
View File
@@ -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
+34
View File
@@ -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(
"""
+3
View File
@@ -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.",
}
+2 -2
View File
@@ -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"],
+28
View File
@@ -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:
+1
View File
@@ -23,6 +23,7 @@
<option value="sweep">Sweep</option>
<option value="fvg">FVG</option>
<option value="range">Range</option>
<option value="mss_displacement">MSS + Displacement</option>
</select>
</label>
<label>Limit <input id="caseLimit" type="number" value="20" min="1" max="100" /></label>
+6 -1
View File
@@ -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) {
+1
View File
@@ -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:
+70 -4
View File
@@ -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: