1336 lines
50 KiB
Python
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()
|