feat: add mss displacement training cases
This commit is contained in:
@@ -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`、回测或实盘链路。
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user