import os import unittest from datetime import datetime, timezone from decimal import Decimal from pathlib import Path os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://user:pass@localhost:5432/ai_ict_test") from scripts.run_migration_static_checks import extract_created_table_columns from src.api import route_request from src.api.training import TrainingAPI from src.services.training import AnnotationService, TrainingCaseGenerator, TrainingCaseRecord from src.services.training.annotation_service import validate_annotation_payload from src.services.training.case_repository import _case_params, _list_cases_query 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, ) from src.services.training.coach_service import build_coach_feedback from src.services.training.grading_service import ScoreItem, TrainingGradingService, grade_annotations from src.services.training.curriculum_service import build_curriculum_path from src.services.training.progress_service import build_progress_summary from src.services.training.replay_case_service import TrainingReplayService class TrainingMigrationTests(unittest.TestCase): def test_training_migration_defines_required_tables(self) -> None: migration = Path("migrations/020_create_training_tables.sql").read_text(encoding="utf-8") schema = extract_created_table_columns(migration) self.assertIn("training_cases", schema) self.assertIn("training_case_tasks", schema) self.assertIn("training_attempts", schema) self.assertIn("training_annotations", schema) self.assertIn("training_scores", schema) self.assertIn("training_progress", schema) self.assertTrue({"case_type", "decision_ts", "answer_key"}.issubset(schema["training_cases"])) self.assertTrue({"attempt_id", "annotation_type", "payload"}.issubset(schema["training_annotations"])) class TrainingRepositoryParamTests(unittest.TestCase): def test_case_params_serialize_json_fields(self) -> None: ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) record = TrainingCaseRecord( instrument_id=1, timeframe="1m", case_type="sweep", chapter_ref="chapter_10", skill_tags=["liquidity", "sweep"], source_event_type="sweep_event", source_event_id=12, window_start_ts=ts, decision_ts=ts, window_end_ts=ts, answer_key={"side_swept": "buy_side"}, status="active", meta={"source": "unit"}, ) params = _case_params(record) self.assertEqual(params["case_type"], "sweep") self.assertEqual(params["skill_tags"], '["liquidity", "sweep"]') self.assertEqual(params["answer_key"], '{"side_swept": "buy_side"}') self.assertEqual(params["meta"], '{"source": "unit"}') def test_list_cases_query_omits_null_optional_filters(self) -> None: sql, params = _list_cases_query(case_type=None, status="active", instrument_id=None, limit=5) sql_text = str(sql) self.assertNotIn("is null", sql_text.lower()) self.assertIn("status = :status", sql_text) self.assertNotIn("case_type = :case_type", sql_text) self.assertNotIn("instrument_id = :instrument_id", sql_text) self.assertEqual(params, {"limit": 5, "status": "active"}) def test_list_cases_query_includes_present_optional_filters(self) -> None: sql, params = _list_cases_query(case_type="sweep", status=None, instrument_id=1, limit=3) sql_text = str(sql) self.assertIn("case_type = :case_type", sql_text) self.assertIn("instrument_id = :instrument_id", sql_text) self.assertNotIn("status = :status", sql_text) self.assertEqual(params, {"limit": 3, "case_type": "sweep", "instrument_id": 1}) class TrainingAnnotationServiceTests(unittest.TestCase): def test_rejects_unsupported_annotation_type(self) -> None: decision = validate_annotation_payload("unknown", {"value": 1}) self.assertFalse(decision.accepted) self.assertEqual(decision.reason, "unsupported_annotation_type") def test_requires_price_bounds_for_zone_annotations(self) -> None: decision = validate_annotation_payload("fvg", {"price_low": 100.0}) 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) decision = service.add_annotation( attempt_id=7, annotation_type="dealing_range", payload={"price_low": 90.0, "price_high": 110.0}, ) self.assertTrue(decision.accepted) self.assertEqual(decision.reason, "accepted") self.assertEqual(len(repo.annotations), 1) self.assertEqual(repo.annotations[0].attempt_id, 7) def test_rejects_invalid_annotation_without_persisting(self) -> None: repo = StubTrainingRepository() service = AnnotationService(repository=repo) decision = service.add_annotation( attempt_id=7, annotation_type="invalidation", payload={"note": "too vague"}, ) self.assertFalse(decision.accepted) self.assertEqual(decision.reason, "missing_invalidation_boundary") self.assertEqual(repo.annotations, []) def test_start_attempt_delegates_to_repository(self) -> None: repo = StubTrainingRepository() service = AnnotationService(repository=repo) attempt_id = service.start_attempt(case_id=3, user_id="local", meta={"mode": "replay"}) self.assertEqual(attempt_id, 101) self.assertEqual(repo.attempts[0].case_id, 3) self.assertEqual(repo.attempts[0].meta, {"mode": "replay"}) class TrainingCaseGeneratorTests(unittest.TestCase): def test_timeframe_to_timedelta_supports_minutes_and_hours(self) -> None: self.assertEqual(timeframe_to_timedelta("5m").total_seconds(), 300) self.assertEqual(timeframe_to_timedelta("1H").total_seconds(), 3600) def test_build_case_window_hides_future_around_decision_ts(self) -> None: decision_ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) window = build_case_window("1m", decision_ts=decision_ts, lookback_bars=2, forward_bars=3) self.assertEqual(window.window_start_ts, datetime(2026, 4, 29, 7, 58, tzinfo=timezone.utc)) self.assertEqual(window.decision_ts, decision_ts) self.assertEqual(window.window_end_ts, datetime(2026, 4, 29, 8, 3, tzinfo=timezone.utc)) def test_build_answer_keys_normalize_decimal_values(self) -> None: ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) sweep = build_sweep_answer_key( { "side_swept": "buy_side", "pool_id": 9, "pool_side": "buy_side", "pool_type": "equal_highs", "pool_price_low": Decimal("100.1"), "pool_price_high": Decimal("100.2"), "sweep_high": Decimal("101.0"), "sweep_low": Decimal("99.0"), "sweep_ts": ts, "close_back_inside": True, "validation_level": "validated", "status": "confirmed", } ) fvg = build_fvg_answer_key( { "direction": "bullish", "upper": Decimal("110.0"), "lower": Decimal("108.5"), "start_ts": ts, "end_ts": ts, "fill_pct": Decimal("0.25"), "status": "open", "related_displacement_id": 3, } ) range_key = build_range_answer_key( { "high": Decimal("120.0"), "low": Decimal("100.0"), "equilibrium": Decimal("110.0"), "high_swing_id": 1, "low_swing_id": 2, "state": "valid", "ts_start": ts, "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_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, 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()) with self.assertRaisesRegex(ValueError, "unsupported_training_case_type"): generator.generate_cases(instrument_id=1, timeframe="1m", case_types=["unknown"]) class TrainingReplayServiceTests(unittest.TestCase): def test_replay_starts_at_decision_frame_and_hides_answer(self) -> None: repo = StubReplayRepository() service = TrainingReplayService(repository=repo) payload = service.build_case_replay(case_id=1) self.assertEqual(payload.frame_index, 2) self.assertEqual(payload.decision_index, 2) self.assertTrue(payload.is_decision_frame) self.assertEqual(len(payload.visible_candles), 3) self.assertIsNone(payload.case["answer_key"]) self.assertFalse(payload.answer_visible) def test_replay_can_reveal_answer_after_submit(self) -> None: repo = StubReplayRepository() service = TrainingReplayService(repository=repo) payload = service.build_case_replay(case_id=1, reveal_answer=True) self.assertEqual(payload.case["answer_key"], {"case_type": "range"}) self.assertTrue(payload.answer_visible) def test_advance_frame_clamps_to_available_window(self) -> None: repo = StubReplayRepository() service = TrainingReplayService(repository=repo) payload = service.advance_frame(case_id=1, current_frame_index=2, step=99) self.assertEqual(payload.frame_index, 4) self.assertTrue(payload.is_complete) def test_advance_frame_can_move_back_to_decision_window(self) -> None: repo = StubReplayRepository() service = TrainingReplayService(repository=repo) payload = service.advance_frame(case_id=1, current_frame_index=4, step=-2) self.assertEqual(payload.frame_index, 2) self.assertTrue(payload.is_decision_frame) class TrainingGradingServiceTests(unittest.TestCase): def test_grade_range_scores_matching_annotations(self) -> None: case = { "case_type": "range", "answer_key": {"low": 100.0, "high": 120.0, "equilibrium": 110.0}, } annotations = [ {"annotation_type": "dealing_range", "payload": {"price_low": 100.0, "price_high": 120.0}}, {"annotation_type": "equilibrium", "payload": {"price": 110.0}}, {"annotation_type": "free_text", "payload": {"text": "等待 Discount 或 Premium 的更优位置"}}, ] scores = grade_annotations(case=case, annotations=annotations) self.assertEqual(sum(item.score for item in scores), 60) self.assertEqual([item.category for item in scores], ["dealing_range", "equilibrium", "location_filter"]) def test_grade_fvg_scores_missing_invalidation_as_zero(self) -> None: case = { "case_type": "fvg", "answer_key": {"lower": 108.0, "upper": 110.0, "status": "open"}, } annotations = [ {"annotation_type": "fvg", "payload": {"price_low": 108.0, "price_high": 110.0}}, {"annotation_type": "multiple_choice", "payload": {"value": "open"}}, ] scores = grade_annotations(case=case, annotations=annotations) 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) result = service.grade_attempt(attempt_id=77) self.assertEqual(result.total_score, 60) self.assertEqual(result.max_score, 60) self.assertEqual(len(repo.scores), 3) self.assertEqual(repo.scores[0].attempt_id, 77) class TrainingCoachServiceTests(unittest.TestCase): def test_coach_feedback_turns_weak_scores_into_next_actions(self) -> None: scores = [ ScoreItem(category="dealing_range", score=20, max_score=20, feedback={"reason": "matched"}), ScoreItem(category="equilibrium", score=0, max_score=20, feedback={"reason": "mismatched_level"}), ScoreItem(category="location_filter", score=0, max_score=20, feedback={"reason": "missing_annotation"}), ] payload = build_coach_feedback(case={"case_type": "range"}, scores=scores, stage_id="foundation_range") self.assertEqual(payload["case_type"], "range") self.assertEqual(payload["stage_id"], "foundation_range") self.assertEqual(payload["score_pct"], 33.33) self.assertEqual([item["category"] for item in payload["weak_categories"]], ["equilibrium", "location_filter"]) self.assertTrue(any("equilibrium" in action.lower() for action in payload["next_actions"])) class TrainingProgressServiceTests(unittest.TestCase): def test_build_progress_summary_groups_by_skill_tags_and_case_type(self) -> None: attempts = [ { "case_type": "sweep", "skill_tags": ["liquidity", "confirmation"], "total_score": 60, "max_score": 60, }, { "case_type": "fvg", "skill_tags": ["pd_array"], "total_score": 20, "max_score": 60, }, ] summary = build_progress_summary(user_id="local", attempts=attempts) self.assertEqual(summary.attempt_count, 2) self.assertEqual(summary.precision_maturity, 66.67) self.assertIn("fvg", summary.weakness_tags) self.assertIn("pd_array", summary.weakness_tags) skill_map = {item.skill_tag: item for item in summary.skill_progress} self.assertEqual(skill_map["liquidity"].avg_score_pct, 100.0) self.assertFalse(skill_map["liquidity"].weakness) class TrainingCurriculumServiceTests(unittest.TestCase): def test_curriculum_starts_with_foundation_open_and_locks_higher_stages(self) -> None: payload = build_curriculum_path(user_id="local", attempts=[]) stage_map = {item["stage_id"]: item for item in payload["stages"]} self.assertEqual(payload["active_stage_id"], "foundation_range") self.assertEqual(stage_map["foundation_range"]["status"], "open") self.assertEqual(stage_map["liquidity_sweep"]["status"], "locked") self.assertEqual(stage_map["integrated_precision"]["status"], "locked") def test_curriculum_unlocks_next_stages_after_foundation_mastery(self) -> None: attempts = [ {"case_type": "range", "skill_tags": ["dealing_range"], "total_score": 50, "max_score": 60}, {"case_type": "range", "skill_tags": ["equilibrium"], "total_score": 48, "max_score": 60}, {"case_type": "range", "skill_tags": ["location"], "total_score": 45, "max_score": 60}, ] payload = build_curriculum_path(user_id="local", attempts=attempts) stage_map = {item["stage_id"]: item for item in payload["stages"]} self.assertEqual(stage_map["foundation_range"]["status"], "mastered") self.assertEqual(stage_map["liquidity_sweep"]["status"], "open") self.assertEqual(stage_map["pd_array_fvg"]["status"], "open") self.assertEqual(payload["active_stage_id"], "liquidity_sweep") class TrainingApiRouteTests(unittest.TestCase): def test_routes_training_cases(self) -> None: status, payload = route_request( "/training/cases", {"case_type": ["sweep"], "limit": ["2"]}, dependencies={"training": StubTrainingAPI()}, ) self.assertEqual(status, 200) self.assertEqual(payload["items"], [{"id": 1, "case_type": "sweep"}]) def test_routes_training_case_detail(self) -> None: status, payload = route_request( "/training/cases/7", {}, dependencies={"training": StubTrainingAPI()}, ) self.assertEqual(status, 200) self.assertEqual(payload["id"], 7) self.assertEqual(payload["tasks"], []) def test_routes_training_replay(self) -> None: status, payload = route_request( "/training/cases/7/replay", {"frame_index": ["3"]}, dependencies={"training": StubTrainingAPI()}, ) self.assertEqual(status, 200) self.assertEqual(payload["case_id"], 7) self.assertEqual(payload["frame_index"], 3) def test_routes_training_generate(self) -> None: status, payload = route_request( "/training/cases/generate", {}, dependencies={"training": StubTrainingAPI()}, method="POST", body={"instrument_id": 1, "timeframe": "5m", "case_types": ["sweep", "fvg"], "limit_per_type": 1}, ) self.assertEqual(status, 200) self.assertEqual(payload["generated"], 2) self.assertEqual(payload["case_types"], ["sweep", "fvg"]) def test_routes_training_progress(self) -> None: status, payload = route_request( "/training/progress", {"user_id": ["local"]}, dependencies={"training": StubTrainingAPI()}, ) self.assertEqual(status, 200) self.assertEqual(payload["precision_maturity"], 82.5) def test_routes_training_attempt_history(self) -> None: status, payload = route_request( "/training/attempts/history", {"user_id": ["local"], "limit": ["2"]}, dependencies={"training": StubTrainingAPI()}, ) self.assertEqual(status, 200) self.assertEqual(payload["items"][0]["attempt_id"], 91) self.assertEqual(payload["items"][0]["case_type"], "range") def test_routes_training_recommendations(self) -> None: status, payload = route_request( "/training/recommendations", {"user_id": ["local"], "limit": ["2"]}, dependencies={"training": StubTrainingAPI()}, ) self.assertEqual(status, 200) self.assertEqual(payload["items"][0]["case_type"], "fvg") self.assertEqual(payload["items"][0]["matched_weakness_tags"], ["fvg"]) def test_routes_training_curriculum(self) -> None: status, payload = route_request( "/training/curriculum", {"user_id": ["local"], "limit_per_stage": ["2"]}, dependencies={"training": StubTrainingAPI()}, ) self.assertEqual(status, 200) self.assertEqual(payload["active_stage_id"], "foundation_range") self.assertEqual(payload["stages"][0]["recommended_cases"][0]["case_type"], "range") def test_routes_training_curriculum_next(self) -> None: status, payload = route_request( "/training/curriculum/next", {}, dependencies={"training": StubTrainingAPI()}, method="POST", body={"user_id": "local", "instrument_id": 1, "stage_id": "foundation_range"}, ) self.assertEqual(status, 200) self.assertEqual(payload["stage"]["stage_id"], "foundation_range") self.assertEqual(payload["case"]["answer_key"], None) self.assertEqual(payload["attempt"]["case_id"], 31) def test_routes_training_start_attempt(self) -> None: status, payload = route_request( "/training/attempts", {}, dependencies={"training": StubTrainingAPI()}, method="POST", body={"case_id": 7, "user_id": "local", "meta": {"mode": "replay"}}, ) self.assertEqual(status, 200) self.assertEqual(payload["attempt"]["case_id"], 7) self.assertEqual(payload["attempt"]["user_id"], "local") def test_routes_training_attempt_detail(self) -> None: status, payload = route_request( "/training/attempts/9", {}, dependencies={"training": StubTrainingAPI()}, ) self.assertEqual(status, 200) self.assertEqual(payload["attempt"]["id"], 9) def test_routes_training_attempt_advance(self) -> None: status, payload = route_request( "/training/attempts/9/advance", {}, dependencies={"training": StubTrainingAPI()}, method="POST", body={"step": 5}, ) self.assertEqual(status, 200) self.assertEqual(payload["attempt"]["id"], 9) self.assertEqual(payload["replay"]["step"], 5) def test_routes_training_attempt_reset(self) -> None: status, payload = route_request( "/training/attempts/9/reset", {}, dependencies={"training": StubTrainingAPI()}, method="POST", body={}, ) self.assertEqual(status, 200) self.assertEqual(payload["attempt"]["id"], 9) self.assertEqual(payload["replay"]["frame_index"], 2) def test_routes_training_attempt_annotation(self) -> None: status, payload = route_request( "/training/attempts/9/annotations", {}, dependencies={"training": StubTrainingAPI()}, method="POST", body={"annotation_type": "dealing_range", "payload": {"price_low": 90, "price_high": 110}}, ) self.assertEqual(status, 200) self.assertTrue(payload["accepted"]) def test_routes_training_attempt_submit(self) -> None: status, payload = route_request( "/training/attempts/9/submit", {}, dependencies={"training": StubTrainingAPI()}, method="POST", body={"reveal_answer": True}, ) self.assertEqual(status, 200) self.assertEqual(payload["attempt"]["status"], "submitted") self.assertTrue(payload["replay"]["answer_visible"]) self.assertIn("coach", payload) self.assertEqual(payload["coach"]["summary"], "Good structure. Clean up the weak categories before advancing.") class TrainingApiAnswerHidingTests(unittest.TestCase): def test_list_cases_hides_answer_key(self) -> None: api = TrainingAPI(repository=StubAnswerHidingRepository()) cases = api.list_cases(limit=2) self.assertEqual(cases[0]["answer_key"], None) def test_get_case_hides_answer_key_but_keeps_tasks(self) -> None: api = TrainingAPI(repository=StubAnswerHidingRepository()) payload = api.get_case(7) self.assertEqual(payload["answer_key"], None) self.assertEqual(payload["tasks"], [{"id": 1, "case_id": 7, "task_type": "mark_fvg"}]) def test_recommendations_prioritize_weakness_and_hide_answer_key(self) -> None: repo = StubRecommendationRepository() api = TrainingAPI(repository=repo) payload = api.get_recommendations(user_id="local", limit=2) self.assertEqual(payload["target_case_types"], ["fvg"]) self.assertEqual(payload["items"][0]["case_type"], "fvg") self.assertEqual(payload["items"][0]["answer_key"], None) self.assertEqual(payload["items"][0]["recommendation_reason"], "weakness") self.assertEqual(payload["items"][0]["matched_weakness_tags"], ["fvg", "pd_array"]) self.assertEqual(repo.case_type_queries[0], "fvg") def test_attempt_history_returns_recent_attempts_without_answers(self) -> None: api = TrainingAPI(repository=StubAttemptHistoryRepository()) payload = api.get_attempt_history(user_id="local", limit=2) self.assertEqual(payload["items"][0]["attempt_id"], 91) self.assertEqual(payload["items"][0]["case_type"], "range") self.assertEqual(payload["items"][0]["score_pct"], 80.0) self.assertNotIn("answer_key", payload["items"][0]) def test_attempt_detail_includes_review_payload_without_case_answer(self) -> None: api = TrainingAPI(repository=StubAttemptDetailRepository()) payload = api.get_attempt(91) self.assertEqual(payload["attempt"]["id"], 91) self.assertEqual(payload["case"]["answer_key"], None) self.assertEqual(payload["case"]["tasks"][0]["task_type"], "mark_dealing_range") self.assertEqual(payload["scores"][0]["category"], "dealing_range") self.assertTrue(payload["replay"]["answer_visible"]) self.assertIsNotNone(payload["replay"]["case"]["answer_key"]) def test_recommendations_balance_case_types_when_no_weakness_exists(self) -> None: repo = StubBalancedRecommendationRepository() api = TrainingAPI(repository=repo) payload = api.get_recommendations(user_id="local", limit=3) self.assertEqual(payload["weakness_tags"], []) self.assertEqual([item["case_type"] for item in payload["items"]], ["sweep", "fvg", "range"]) self.assertEqual(repo.case_type_queries[:3], ["sweep", "fvg", "range"]) def test_curriculum_includes_unlocked_recommendations_without_answers(self) -> None: api = TrainingAPI(repository=StubCurriculumRepository()) payload = api.get_curriculum(user_id="local", limit_per_stage=2) stage_map = {item["stage_id"]: item for item in payload["stages"]} self.assertEqual(stage_map["foundation_range"]["status"], "mastered") self.assertEqual(stage_map["liquidity_sweep"]["status"], "open") self.assertEqual(stage_map["liquidity_sweep"]["recommended_cases"][0]["case_type"], "sweep") self.assertEqual(stage_map["liquidity_sweep"]["recommended_cases"][0]["answer_key"], None) self.assertEqual(stage_map["integrated_precision"]["recommended_cases"], []) def test_start_curriculum_drill_creates_attempt_for_unlocked_stage(self) -> None: repo = StubCurriculumDrillRepository() api = TrainingAPI(repository=repo) payload = api.start_curriculum_drill(user_id="local", stage_id="foundation_range") self.assertEqual(payload["stage"]["stage_id"], "foundation_range") self.assertEqual(payload["case"]["answer_key"], None) self.assertEqual(payload["attempt"]["case_id"], 31) self.assertEqual(repo.attempts[0].meta["stage_id"], "foundation_range") self.assertEqual(payload["replay"]["frame_index"], 1) def test_submit_attempt_returns_refreshed_curriculum_and_next_drill(self) -> None: api = TrainingAPI(repository=StubSubmitFollowupRepository()) payload = api.submit_attempt(91) self.assertEqual(payload["curriculum"]["active_stage_id"], "liquidity_sweep") self.assertEqual(payload["next_drill"]["stage_id"], "liquidity_sweep") self.assertEqual(payload["next_drill"]["case"]["case_type"], "sweep") self.assertEqual(payload["next_drill"]["case"]["answer_key"], None) self.assertEqual(payload["stage_transition"]["from_active_stage_id"], "foundation_range") self.assertEqual(payload["stage_transition"]["to_active_stage_id"], "liquidity_sweep") self.assertTrue(payload["stage_transition"]["advanced"]) self.assertEqual(payload["stage_transition"]["mastered_stage_ids"], ["foundation_range"]) self.assertEqual(payload["stage_transition"]["unlocked_stage_ids"], ["liquidity_sweep", "pd_array_fvg"]) self.assertEqual(payload["total_score"], 60) def test_reset_attempt_returns_to_decision_frame(self) -> None: repo = StubResetAttemptRepository() api = TrainingAPI(repository=repo) payload = api.reset_attempt(77) self.assertEqual(payload["attempt"]["current_frame_index"], 2) self.assertEqual(payload["replay"]["frame_index"], 2) self.assertTrue(payload["replay"]["is_decision_frame"]) self.assertEqual(repo.updated_frame_index, 2) def test_replay_controls_lock_after_attempt_is_graded(self) -> None: repo = StubLockedReplayAttemptRepository() api = TrainingAPI(repository=repo) with self.assertRaisesRegex(ValueError, "training_attempt_replay_locked:graded"): api.advance_attempt(77, step=1) with self.assertRaisesRegex(ValueError, "training_attempt_replay_locked:graded"): api.reset_attempt(77) self.assertFalse(repo.update_called) class StubTrainingRepository: def __init__(self) -> None: self.annotations = [] self.attempts = [] def insert_attempt(self, record): self.attempts.append(record) return 101 def insert_annotation(self, record): self.annotations.append(record) return 202 class StubCaseGenerationRepository: def __init__(self) -> None: self.cases = [] self.replaced_tasks = {} self.next_id = 1000 self.ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) def insert_case(self, record): self.next_id += 1 self.cases.append(record) return self.next_id def replace_tasks(self, case_id, tasks): self.replaced_tasks[case_id] = tasks return len(tasks) def fetch_sweep_sources(self, **kwargs): return [ { "id": 11, "side_swept": "buy_side", "pool_id": 5, "sweep_high": Decimal("101.0"), "sweep_low": Decimal("99.0"), "sweep_ts": self.ts, "close_back_inside": True, "validation_level": "validated", "status": "confirmed", "pool_side": "buy_side", "pool_type": "session_high", "pool_price_low": Decimal("100.0"), "pool_price_high": Decimal("100.0"), } ] def fetch_fvg_sources(self, **kwargs): return [ { "id": 21, "direction": "bullish", "upper": Decimal("110.0"), "lower": Decimal("108.0"), "start_ts": self.ts, "end_ts": self.ts, "fill_pct": Decimal("0.0"), "status": "open", "related_displacement_id": 7, } ] def fetch_range_sources(self, **kwargs): return [ { "id": 31, "high": Decimal("120.0"), "low": Decimal("100.0"), "equilibrium": Decimal("110.0"), "high_swing_id": 1, "low_swing_id": 2, "state": "valid", "ts_start": self.ts, "ts_end": None, } ] 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: self.ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) def fetch_case(self, case_id): return { "id": case_id, "instrument_id": 1, "timeframe": "1m", "case_type": "range", "decision_ts": datetime(2026, 4, 29, 8, 2, tzinfo=timezone.utc), "answer_key": {"case_type": "range"}, } def fetch_case_candles(self, case_id): base = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) candles = [] for index in range(5): candles.append( { "ts_open": base.replace(minute=base.minute + index), "open": 100 + index, "high": 101 + index, "low": 99 + index, "close": 100.5 + index, } ) return candles class StubTrainingAPI: def list_cases(self, **kwargs): return [{"id": 1, "case_type": kwargs["case_type"]}] def get_case(self, case_id): return {"id": case_id, "tasks": []} def get_replay(self, case_id, frame_index=None, reveal_answer=False): return { "case_id": case_id, "frame_index": frame_index, "answer_visible": reveal_answer, } def generate_cases(self, instrument_id, timeframe, case_types=None, limit_per_type=10): return { "generated": len(case_types or []), "instrument_id": instrument_id, "timeframe": timeframe, "case_types": case_types, "limit_per_type": limit_per_type, } def start_attempt(self, case_id, user_id="local", meta=None): return { "attempt": {"id": 9, "case_id": case_id, "user_id": user_id, "meta": meta or {}}, "replay": {"frame_index": 2}, } def get_attempt(self, attempt_id): return {"attempt": {"id": attempt_id}, "annotations": []} def advance_attempt(self, attempt_id, step=1): return {"attempt": {"id": attempt_id}, "replay": {"step": step}} def reset_attempt(self, attempt_id): return {"attempt": {"id": attempt_id}, "replay": {"frame_index": 2}} def add_annotation(self, attempt_id, annotation_type, payload): return {"accepted": True, "reason": "accepted", "annotations": [{"annotation_type": annotation_type, "payload": payload}]} def submit_attempt(self, attempt_id, reveal_answer=True): return { "attempt": {"id": attempt_id, "status": "submitted"}, "annotations": [], "coach": {"summary": "Good structure. Clean up the weak categories before advancing."}, "replay": {"answer_visible": reveal_answer}, } def get_progress(self, user_id="local", limit=200): return { "user_id": user_id, "attempt_count": 2, "precision_maturity": 82.5, "weakness_tags": [], "skill_progress": [], } def get_attempt_history(self, user_id="local", limit=20): return { "user_id": user_id, "items": [{"attempt_id": 91, "case_id": 31, "case_type": "range", "score_pct": 80.0}], } def get_recommendations(self, user_id="local", limit=5, instrument_id=None): return { "user_id": user_id, "weakness_tags": ["fvg"], "target_case_types": ["fvg"], "items": [ { "id": 7, "case_type": "fvg", "answer_key": None, "matched_weakness_tags": ["fvg"], } ], } def get_curriculum(self, user_id="local", instrument_id=None, limit_per_stage=2): return { "user_id": user_id, "active_stage_id": "foundation_range", "stages": [ { "stage_id": "foundation_range", "status": "open", "recommended_cases": [{"id": 1, "case_type": "range", "answer_key": None}], } ], } def start_curriculum_drill(self, user_id="local", instrument_id=None, stage_id=None): return { "stage": {"stage_id": stage_id or "foundation_range", "title": "Foundation Range"}, "case": {"id": 31, "case_type": "range", "answer_key": None}, "attempt": {"id": 91, "case_id": 31, "user_id": user_id}, "replay": {"frame_index": 1}, } class StubAnswerHidingRepository: def list_cases(self, **kwargs): return [ { "id": 7, "case_type": "fvg", "answer_key": {"lower": 100.0, "upper": 101.0}, } ] def fetch_case(self, case_id): return { "id": case_id, "case_type": "fvg", "answer_key": {"lower": 100.0, "upper": 101.0}, } def list_tasks(self, case_id): return [{"id": 1, "case_id": case_id, "task_type": "mark_fvg"}] class StubRecommendationRepository: def __init__(self) -> None: self.case_type_queries = [] def fetch_progress_attempts(self, user_id="local", limit=200): return [ { "case_type": "fvg", "skill_tags": ["pd_array", "imbalance"], "total_score": 20, "max_score": 60, } ] def list_cases(self, case_type=None, status="active", instrument_id=None, limit=50): self.case_type_queries.append(case_type) if case_type == "fvg": return [ { "id": 11, "case_type": "fvg", "skill_tags": ["pd_array", "fvg"], "answer_key": {"lower": 100.0, "upper": 101.0}, } ] return [ { "id": 12, "case_type": "range", "skill_tags": ["dealing_range"], "answer_key": {"low": 90.0, "high": 110.0}, } ] class StubAttemptHistoryRepository: def list_attempt_history(self, user_id="local", limit=20): return [ { "attempt_id": 91, "case_id": 31, "user_id": user_id, "case_type": "range", "status": "graded", "total_score": 48, "max_score": 60.0, "score_pct": 80.0, } ][:limit] class StubAttemptDetailRepository: def __init__(self) -> None: self.ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) def fetch_attempt(self, attempt_id): return { "id": attempt_id, "case_id": 31, "user_id": "local", "status": "graded", "current_frame_index": 1, "total_score": 60, "meta": {}, } def fetch_case(self, case_id): return { "id": case_id, "instrument_id": 1, "timeframe": "1m", "case_type": "range", "decision_ts": self.ts.replace(minute=1), "answer_key": {"low": 90.0, "high": 110.0, "equilibrium": 100.0}, } def list_tasks(self, case_id): return [{"id": 1, "case_id": case_id, "task_type": "mark_dealing_range"}] def list_scores(self, attempt_id): return [{"category": "dealing_range", "score": 20, "max_score": 20, "feedback": {"reason": "matched"}}] def list_annotations(self, attempt_id): return [{"annotation_type": "dealing_range", "payload": {"price_low": 90, "price_high": 110}}] def fetch_case_candles(self, case_id): return [ {"ts_open": self.ts, "open": 100, "high": 101, "low": 99, "close": 100}, {"ts_open": self.ts.replace(minute=1), "open": 100, "high": 102, "low": 98, "close": 101}, ] class StubBalancedRecommendationRepository: def __init__(self) -> None: self.case_type_queries = [] def fetch_progress_attempts(self, user_id="local", limit=200): return [] def list_cases(self, case_type=None, status="active", instrument_id=None, limit=50): self.case_type_queries.append(case_type) if case_type is None: return [] return [ { "id": {"sweep": 21, "fvg": 22, "range": 23}[case_type], "case_type": case_type, "skill_tags": [case_type], "answer_key": {"hidden": True}, } ] class StubCurriculumRepository: def fetch_progress_attempts(self, user_id="local", limit=500): return [ {"case_type": "range", "skill_tags": ["dealing_range"], "total_score": 50, "max_score": 60}, {"case_type": "range", "skill_tags": ["equilibrium"], "total_score": 48, "max_score": 60}, {"case_type": "range", "skill_tags": ["location"], "total_score": 45, "max_score": 60}, ] def list_cases(self, case_type=None, status="active", instrument_id=None, limit=50): return [ { "id": {"range": 31, "sweep": 32, "fvg": 33}.get(case_type, 99), "case_type": case_type, "skill_tags": [case_type], "answer_key": {"hidden": True}, } ][:limit] class StubCurriculumDrillRepository: def __init__(self) -> None: self.ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) self.attempts = [] def fetch_progress_attempts(self, user_id="local", limit=500): return [] def list_cases(self, case_type=None, status="active", instrument_id=None, limit=50): if case_type == "range": return [ { "id": 31, "case_type": "range", "skill_tags": ["dealing_range"], "answer_key": {"low": 90.0, "high": 110.0}, } ] return [] def fetch_case(self, case_id): return { "id": case_id, "case_type": "range", "decision_ts": self.ts.replace(minute=1), "answer_key": {"low": 90.0, "high": 110.0}, } def list_tasks(self, case_id): return [{"id": 1, "case_id": case_id, "task_type": "mark_dealing_range"}] def fetch_case_candles(self, case_id): return [ {"ts_open": self.ts, "open": 100, "high": 101, "low": 99, "close": 100}, {"ts_open": self.ts.replace(minute=1), "open": 100, "high": 102, "low": 98, "close": 101}, ] def insert_attempt(self, record): self.attempts.append(record) return 91 def fetch_attempt(self, attempt_id): return { "id": attempt_id, "case_id": self.attempts[0].case_id, "user_id": self.attempts[0].user_id, "current_frame_index": self.attempts[0].current_frame_index, "meta": self.attempts[0].meta, } class StubSubmitFollowupRepository: def __init__(self) -> None: self.ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) self.scores = [] self.submitted_total_score = None def fetch_attempt(self, attempt_id): return { "id": attempt_id, "case_id": 31, "user_id": "local", "current_frame_index": 1, "status": "submitted" if self.submitted_total_score is not None else "started", "total_score": self.submitted_total_score, "meta": {"stage_id": "foundation_range"}, } def fetch_case(self, case_id): return { "id": case_id, "instrument_id": 1, "timeframe": "1m", "case_type": "range", "decision_ts": self.ts.replace(minute=1), "skill_tags": ["dealing_range", "equilibrium"], "answer_key": {"low": 90.0, "high": 110.0, "equilibrium": 100.0}, } def list_annotations(self, attempt_id): return [ {"annotation_type": "dealing_range", "payload": {"price_low": 90.0, "price_high": 110.0}}, {"annotation_type": "equilibrium", "payload": {"price": 100.0}}, {"annotation_type": "free_text", "payload": {"text": "wait for discount"}}, ] def upsert_score(self, record): self.scores.append(record) return len(self.scores) def submit_attempt(self, attempt_id, total_score): self.submitted_total_score = total_score return None def fetch_case_candles(self, case_id): return [ {"ts_open": self.ts, "open": 100, "high": 101, "low": 99, "close": 100}, {"ts_open": self.ts.replace(minute=1), "open": 100, "high": 102, "low": 98, "close": 101}, ] def fetch_progress_attempts(self, user_id="local", limit=500): if self.submitted_total_score is None: return [] return [ {"case_type": "range", "skill_tags": ["dealing_range"], "total_score": 50, "max_score": 60}, {"case_type": "range", "skill_tags": ["equilibrium"], "total_score": 48, "max_score": 60}, {"case_type": "range", "skill_tags": ["location"], "total_score": 45, "max_score": 60}, ] def list_cases(self, case_type=None, status="active", instrument_id=None, limit=50): if case_type == "sweep": return [ { "id": 32, "case_type": "sweep", "skill_tags": ["liquidity", "sweep"], "answer_key": {"hidden": True}, } ] if case_type == "fvg": return [ { "id": 33, "case_type": "fvg", "skill_tags": ["pd_array", "fvg"], "answer_key": {"hidden": True}, } ] return [] class StubResetAttemptRepository: def __init__(self) -> None: self.ts = datetime(2026, 4, 29, 8, 0, tzinfo=timezone.utc) self.updated_frame_index = 4 def fetch_attempt(self, attempt_id): return { "id": attempt_id, "case_id": 1, "user_id": "local", "status": "started", "current_frame_index": self.updated_frame_index, "meta": {}, } def update_attempt_frame(self, attempt_id, current_frame_index): self.updated_frame_index = current_frame_index return None def fetch_case(self, case_id): return { "id": case_id, "instrument_id": 1, "timeframe": "1m", "case_type": "range", "decision_ts": self.ts.replace(minute=2), "answer_key": {"case_type": "range"}, } def fetch_case_candles(self, case_id): return [ {"ts_open": self.ts.replace(minute=index), "open": 100, "high": 101, "low": 99, "close": 100} for index in range(5) ] class StubLockedReplayAttemptRepository(StubResetAttemptRepository): def __init__(self) -> None: super().__init__() self.update_called = False def fetch_attempt(self, attempt_id): attempt = super().fetch_attempt(attempt_id) attempt["status"] = "graded" return attempt def update_attempt_frame(self, attempt_id, current_frame_index): self.update_called = True super().update_attempt_frame(attempt_id, current_frame_index) class StubGradingRepository: def __init__(self) -> None: self.scores = [] def fetch_attempt(self, attempt_id): return {"id": attempt_id, "case_id": 3} def fetch_case(self, case_id): return { "id": case_id, "case_type": "sweep", "answer_key": { "pool_price_low": 100.0, "pool_price_high": 100.0, "validation_level": "validated", "status": "confirmed", "close_back_inside": True, }, } def list_annotations(self, attempt_id): return [ {"annotation_type": "liquidity_pool", "payload": {"price_low": 100.0, "price_high": 100.0}}, {"annotation_type": "multiple_choice", "payload": {"value": "validated"}}, {"annotation_type": "invalidation", "payload": {"condition": "close back outside pool"}}, ] def upsert_score(self, record): self.scores.append(record) return len(self.scores) if __name__ == "__main__": unittest.main()