Files
ai-exchange/tests/unit/test_training_module.py
T
2026-04-30 01:15:28 +08:00

1336 lines
50 KiB
Python

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()