Files
ai-exchange/tests/unit/test_training_api_m2.py
T

290 lines
11 KiB
Python

import io
import json
import re
import unittest
from pathlib import Path
from src.api.server import APIRequestHandler, route_request
from src.api.training import (
add_training_annotation,
create_training_attempt,
get_training_case,
get_training_cases,
get_training_progress,
submit_training_attempt,
)
from src.services.training import TrainingCaseService
class _FakeTrainingRepository:
def __init__(self) -> None:
self.list_args = None
self.get_args = None
self.created_attempt = None
self.annotation = None
self.submitted_attempt = None
self.progress_args = None
def list_cases(self, status=None, skill_tag=None, instrument_id=None, limit=100):
self.list_args = {
"status": status,
"skill_tag": skill_tag,
"instrument_id": instrument_id,
"limit": limit,
}
return [
{
"id": 7,
"case_type": "sweep",
"skill_tag": "liquidity_pool",
"answer_key": {"side": "buy_side"},
}
]
def get_case(self, case_id: int, include_answer_key: bool = False):
self.get_args = {"case_id": case_id, "include_answer_key": include_answer_key}
return {
"id": case_id,
"case_type": "sweep",
"skill_tag": "liquidity_pool",
"answer_key": {"side": "buy_side"},
}
def create_attempt(self, case_id, user_id="local", status="started", current_frame_index=0, meta=None):
self.created_attempt = {
"case_id": case_id,
"user_id": user_id,
"status": status,
"current_frame_index": current_frame_index,
"meta": meta,
}
return 101
def add_annotation(self, attempt_id, annotation_type, payload, user_id="local", task_id=None):
self.annotation = {
"attempt_id": attempt_id,
"annotation_type": annotation_type,
"payload": payload,
"user_id": user_id,
"task_id": task_id,
}
return 202
def submit_attempt(self, attempt_id, total_score, status="submitted"):
self.submitted_attempt = {"attempt_id": attempt_id, "total_score": total_score, "status": status}
def get_progress(self, user_id="local", skill_tag=None, limit=100):
self.progress_args = {"user_id": user_id, "skill_tag": skill_tag, "limit": limit}
return [{"user_id": user_id, "skill_tag": skill_tag or "liquidity_pool", "attempt_count": 1}]
def _dependencies(service: TrainingCaseService) -> dict:
return {
"training_cases": lambda **kwargs: get_training_cases(service=service, **kwargs),
"training_case": lambda **kwargs: get_training_case(service=service, **kwargs),
"training_create_attempt": lambda body: create_training_attempt(body, service=service),
"training_add_annotation": lambda attempt_id, body: add_training_annotation(attempt_id, body, service=service),
"training_submit_attempt": lambda attempt_id, body: submit_training_attempt(attempt_id, body, service=service),
"training_progress": lambda **kwargs: get_training_progress(service=service, **kwargs),
}
class _PostHandlerHarness(APIRequestHandler):
def __init__(self, path: str, body: bytes, dependencies: dict) -> None:
self.path = path
self.headers = {"Content-Length": str(len(body))}
self.rfile = io.BytesIO(body)
self.wfile = io.BytesIO()
self.dependencies = dependencies
self.status = None
self.response_headers = {}
def send_response(self, code, message=None): # noqa: D401
self.status = code
def send_header(self, keyword, value):
self.response_headers[keyword] = value
def end_headers(self):
return None
def log_message(self, format, *args):
return None
def json_body(self):
return json.loads(self.wfile.getvalue().decode("utf-8"))
class TrainingApiM2Tests(unittest.TestCase):
def setUp(self) -> None:
self.repository = _FakeTrainingRepository()
self.service = TrainingCaseService(repository=self.repository)
self.dependencies = _dependencies(self.service)
def test_get_training_cases_hides_answer_key(self) -> None:
status, payload = route_request(
"/training/cases",
{"status": ["published"], "skill_tag": ["liquidity_pool"], "instrument_id": ["1"], "limit": ["5"]},
dependencies=self.dependencies,
)
self.assertEqual(status, 200)
self.assertEqual(payload["items"][0]["id"], 7)
self.assertEqual(payload["items"][0]["quality"]["rank"], "low")
self.assertNotIn("answer_key", payload["items"][0])
self.assertEqual(
self.repository.list_args,
{"status": "published", "skill_tag": "liquidity_pool", "instrument_id": 1, "limit": 5},
)
def test_get_training_case_hides_answer_key(self) -> None:
status, payload = route_request("/training/cases/7", {}, dependencies=self.dependencies)
self.assertEqual(status, 200)
self.assertEqual(payload["id"], 7)
self.assertEqual(payload["quality"]["rank"], "low")
self.assertNotIn("answer_key", payload)
self.assertEqual(self.repository.get_args, {"case_id": 7, "include_answer_key": False})
def test_get_training_cases_supports_quality_selector_filters_and_sort(self) -> None:
class QualityRepository(_FakeTrainingRepository):
def list_cases(self, status=None, skill_tag=None, instrument_id=None, limit=100):
self.list_args = {
"status": status,
"skill_tag": skill_tag,
"instrument_id": instrument_id,
"limit": limit,
}
return [
{
"id": 7,
"case_type": "sweep",
"skill_tag": "liquidity_pool",
"status": "published",
"source_event_refs": [{"kind": "sweep_event", "id": 1}],
"meta": {
"title": "Higher quality case",
"skill_tags": ["liquidity_pool", "context_reading"],
"rubric": [{"category": "context", "max_score": 5}],
"tasks": [{"task_type": "note", "prompt": "Explain context."}],
"chapter_ref": "chapter_01",
},
"start_ts": "2026-04-01T09:00:00Z",
"decision_ts": "2026-04-01T10:00:00Z",
"end_ts": "2026-04-01T11:00:00Z",
},
{
"id": 8,
"case_type": "sweep",
"skill_tag": "liquidity_pool",
"status": "draft",
"meta": {"title": "Lower quality case"},
},
]
repository = QualityRepository()
service = TrainingCaseService(repository=repository)
dependencies = _dependencies(service)
status, payload = route_request(
"/training/cases",
{
"quality_rank": ["high"],
"min_quality_score": ["80"],
"sort_by": ["quality_score_desc"],
},
dependencies=dependencies,
)
self.assertEqual(status, 200)
self.assertEqual([item["id"] for item in payload["items"]], [7])
self.assertEqual(payload["items"][0]["quality"]["rank"], "high")
self.assertGreaterEqual(payload["items"][0]["quality"]["score"], 80)
def test_post_training_attempt_creates_attempt(self) -> None:
status, payload = route_request(
"/training/attempts",
{},
dependencies=self.dependencies,
method="POST",
body={"case_id": 7, "user_id": "analyst", "current_frame_index": 3, "meta": {"source": "unit"}},
)
self.assertEqual(status, 201)
self.assertEqual(payload, {"id": 101})
self.assertEqual(
self.repository.created_attempt,
{
"case_id": 7,
"user_id": "analyst",
"status": "started",
"current_frame_index": 3,
"meta": {"source": "unit"},
},
)
def test_annotation_submit_and_progress_routes(self) -> None:
annotation_status, annotation_payload = route_request(
"/training/attempts/101/annotations",
{},
dependencies=self.dependencies,
method="POST",
body={
"annotation_type": "liquidity_zone",
"payload": {"price_low": 100.0, "price_high": 101.0},
"task_id": 11,
"user_id": "analyst",
},
)
submit_status, submit_payload = route_request(
"/training/attempts/101/submit",
{},
dependencies=self.dependencies,
method="POST",
body={"total_score": 8.5},
)
progress_status, progress_payload = route_request(
"/training/progress",
{"user_id": ["analyst"], "skill_tag": ["liquidity_pool"], "limit": ["1"]},
dependencies=self.dependencies,
)
self.assertEqual(annotation_status, 201)
self.assertEqual(annotation_payload, {"id": 202})
self.assertEqual(self.repository.annotation["attempt_id"], 101)
self.assertEqual(submit_status, 200)
self.assertEqual(submit_payload, {"id": 101, "status": "submitted"})
self.assertEqual(self.repository.submitted_attempt, {"attempt_id": 101, "total_score": 8.5, "status": "submitted"})
self.assertEqual(progress_status, 200)
self.assertEqual(progress_payload["items"][0]["user_id"], "analyst")
self.assertEqual(self.repository.progress_args, {"user_id": "analyst", "skill_tag": "liquidity_pool", "limit": 1})
def test_post_invalid_json_or_missing_required_field_returns_400(self) -> None:
invalid_json_handler = _PostHandlerHarness("/training/attempts", b"{", self.dependencies)
invalid_json_handler.do_POST()
missing_field_handler = _PostHandlerHarness("/training/attempts", b"{}", self.dependencies)
missing_field_handler.do_POST()
self.assertEqual(invalid_json_handler.status, 400)
self.assertEqual(invalid_json_handler.json_body()["error"], "invalid_json_body")
self.assertEqual(missing_field_handler.status, 400)
self.assertEqual(missing_field_handler.json_body()["error"], "missing_required_json_field:case_id")
def test_training_api_source_does_not_write_trading_chain_tables(self) -> None:
paths = [
Path("src/api/training.py"),
Path("src/api/server.py"),
Path("src/services/training/training_case_service.py"),
Path(__file__),
]
source = "\n".join(path.read_text(encoding="utf-8") for path in paths if path.exists()).lower()
for table_name in ("trade_signals", "trade_executions", "backtest_results"):
forbidden = re.compile(rf"\b(insert\s+into|update|delete\s+from|truncate\s+table)\s+{table_name}\b")
self.assertIsNone(forbidden.search(source), f"training API writes to {table_name}")
if __name__ == "__main__":
unittest.main()