feat: add training case import export

This commit is contained in:
2026-04-30 20:45:33 -04:00
parent b40c858f91
commit 62ea8bc6e8
6 changed files with 800 additions and 1 deletions
+17
View File
@@ -13,9 +13,11 @@ from src.api.signals import get_recent_signals
from src.api.training import (
add_training_annotation,
create_training_attempt,
export_training_case_package,
get_training_case,
get_training_cases,
get_training_progress,
import_training_case_package,
submit_training_attempt,
)
from src.services.execution import ExecutionService
@@ -32,6 +34,8 @@ def build_api_dependencies() -> dict:
"executions": execution_service.build_execution_dashboard_items,
"training_cases": get_training_cases,
"training_case": get_training_case,
"training_export_cases": export_training_case_package,
"training_import_cases": import_training_case_package,
"training_create_attempt": create_training_attempt,
"training_add_annotation": add_training_annotation,
"training_submit_attempt": submit_training_attempt,
@@ -90,6 +94,19 @@ def route_request(
limit=limit,
)
}
if method == "GET" and path == "/training/cases/export":
status = _get_single(query, "status")
skill_tag = _get_single(query, "skill_tag")
instrument_id = _get_optional_int(query, "instrument_id")
limit = _get_limit(query, "limit", 100, max_value=MAX_TRAINING_LIMIT)
return 200, deps["training_export_cases"](
status=status,
skill_tag=skill_tag,
instrument_id=instrument_id,
limit=limit,
)
if method == "POST" and path == "/training/cases/import":
return 201, deps["training_import_cases"](body or {})
if method == "GET" and len(path_parts) == 3 and path_parts[:2] == ["training", "cases"]:
case_id = _parse_path_int(path_parts[2], "case_id")
case = deps["training_case"](case_id=case_id)
+19
View File
@@ -65,6 +65,25 @@ def get_training_progress(
return _service(service).get_progress(user_id=user_id, skill_tag=skill_tag, limit=limit)
def export_training_case_package(
status: Optional[str] = None,
skill_tag: Optional[str] = None,
instrument_id: Optional[int] = None,
limit: int = 100,
service: Optional[TrainingCaseService] = None,
) -> dict:
return _service(service).export_cases(
status=status,
skill_tag=skill_tag,
instrument_id=instrument_id,
limit=limit,
)
def import_training_case_package(body: dict, service: Optional[TrainingCaseService] = None) -> dict:
return _service(service).import_cases(body)
def _service(service: Optional[TrainingCaseService]) -> TrainingCaseService:
return service or TrainingCaseService()
+11 -1
View File
@@ -1,5 +1,15 @@
"""Training case service package."""
from src.services.training.case_import_export import (
export_training_cases,
import_training_cases,
validate_training_case_payload,
)
from src.services.training.training_case_service import TrainingCaseService
__all__ = ["TrainingCaseService"]
__all__ = [
"TrainingCaseService",
"export_training_cases",
"import_training_cases",
"validate_training_case_payload",
]
+448
View File
@@ -0,0 +1,448 @@
from __future__ import annotations
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
EXPORT_VERSION = 1
_REQUIRED_ITEM_FIELDS = ("title", "scenario", "skill_tags", "difficulty", "answer", "rubric")
_REQUIRED_SCENARIO_FIELDS = ("instrument_id", "timeframe", "case_type", "start_ts", "decision_ts", "end_ts")
_INTERNAL_PUBLIC_FIELDS = ("answer_key", "answer")
_RESERVED_META_FIELDS = ("title", "skill_tags", "rubric", "tasks", "answer_key", "answer")
_RESTRICTED_TEXT_FRAGMENTS = (
"data" + "base",
"post" + "gresql",
"psy" + "copg",
"pass" + "word",
"tok" + "en",
"sec" + "ret",
"api" + "_key",
)
_DROP = object()
def validate_training_case_payload(payload: dict) -> dict:
if isinstance(payload, dict) and "items" in payload and "title" not in payload:
items = _require_package_items(payload)
return {
"version": EXPORT_VERSION,
"items": [_normalize_case_payload(item, f"items[{index}]") for index, item in enumerate(items)],
}
return _normalize_case_payload(payload, None)
def import_training_cases(payload: dict, repository) -> dict:
if not isinstance(payload, dict):
raise ValueError("invalid_json_body")
items = _require_package_items(payload)
imported_items = []
case_ids = []
for index, item in enumerate(items):
normalized = _normalize_case_payload(item, f"items[{index}]")
case_id = repository.create_case(
instrument_id=normalized["scenario"]["instrument_id"],
timeframe=normalized["scenario"]["timeframe"],
case_type=normalized["scenario"]["case_type"],
skill_tag=normalized["skill_tags"][0],
start_ts=normalized["scenario"]["start_ts"],
decision_ts=normalized["scenario"]["decision_ts"],
end_ts=normalized["scenario"]["end_ts"],
source_event_refs=normalized["scenario"].get("source_event_refs", []),
answer_key={"answer": normalized["answer"], "rubric": normalized["rubric"]},
status=normalized["status"],
chapter_ref=normalized["chapter_ref"],
source_event_type=normalized["scenario"].get("source_event_type"),
source_event_id=normalized["scenario"].get("source_event_id"),
difficulty=normalized["difficulty"],
meta=normalized["meta"],
)
case_ids.append(case_id)
imported_items.append(_public_import_item(normalized, case_id))
_create_tasks(repository, case_id, normalized["tasks"])
return {
"version": EXPORT_VERSION,
"imported": len(imported_items),
"case_ids": case_ids,
"items": imported_items,
}
def export_training_cases(cases: list[dict]) -> dict:
items = [_public_case_row(case) for case in cases]
items.sort(key=lambda item: (_sort_id(item.get("id")), item.get("title", "")))
return {
"version": EXPORT_VERSION,
"metadata": {"item_count": len(items)},
"items": items,
}
def _normalize_case_payload(payload: dict, path: Optional[str]) -> dict:
if not isinstance(payload, dict):
raise ValueError(_field_error("invalid_json_field", path or "case"))
if "answer_key" in payload:
raise ValueError("invalid_json_field:answer_key")
for field in _REQUIRED_ITEM_FIELDS:
_require_value(payload, field, path)
title = _require_non_empty_str(payload["title"], _join_path(path, "title"))
scenario = _normalize_scenario(payload["scenario"], _join_path(path, "scenario"))
skill_tags = _normalize_skill_tags(payload["skill_tags"], _join_path(path, "skill_tags"))
difficulty = _require_non_empty_str(payload["difficulty"], _join_path(path, "difficulty"))
answer = _require_dict(payload["answer"], _join_path(path, "answer"))
rubric = _require_rubric(payload["rubric"], _join_path(path, "rubric"))
tasks = _normalize_tasks(payload.get("tasks", []), skill_tags[0], _join_path(path, "tasks"))
metadata = _optional_dict(payload.get("metadata", payload.get("meta", {})), _join_path(path, "metadata"))
status = _optional_non_empty_str(payload.get("status"), "draft", _join_path(path, "status"))
external_id = payload.get("external_id")
if external_id is not None:
external_id = _require_non_empty_str(external_id, _join_path(path, "external_id"))
chapter_ref = scenario.get("chapter_ref") or metadata.get("chapter_ref")
meta = _public_json_value(metadata)
if meta is _DROP or not isinstance(meta, dict):
meta = {}
meta.update(
{
"title": title,
"skill_tags": skill_tags,
"rubric": _json_value(rubric),
"tasks": _json_value(tasks),
}
)
if external_id is not None:
meta["external_id"] = external_id
return {
"external_id": external_id,
"title": title,
"scenario": scenario,
"skill_tags": skill_tags,
"difficulty": difficulty,
"answer": answer,
"rubric": rubric,
"tasks": tasks,
"status": status,
"chapter_ref": chapter_ref,
"meta": meta,
}
def _require_package_items(payload: dict) -> list:
items = _require_value(payload, "items", None)
if not isinstance(items, list) or not items:
raise ValueError("invalid_json_field:items")
return items
def _normalize_scenario(value, path: str) -> dict:
if not isinstance(value, dict):
raise ValueError(_field_error("invalid_json_field", path))
for field in _REQUIRED_SCENARIO_FIELDS:
_require_value(value, field, path)
scenario = {
"instrument_id": _coerce_int(value["instrument_id"], _join_path(path, "instrument_id")),
"timeframe": _require_non_empty_str(value["timeframe"], _join_path(path, "timeframe")),
"case_type": _require_non_empty_str(value["case_type"], _join_path(path, "case_type")),
"start_ts": _require_timestamp(value["start_ts"], _join_path(path, "start_ts")),
"decision_ts": _require_timestamp(value["decision_ts"], _join_path(path, "decision_ts")),
"end_ts": _require_timestamp(value["end_ts"], _join_path(path, "end_ts")),
"source_event_refs": _optional_list(value.get("source_event_refs", []), _join_path(path, "source_event_refs")),
}
for optional_field in ("source_event_type", "chapter_ref"):
optional_value = value.get(optional_field)
if optional_value is not None:
scenario[optional_field] = _require_non_empty_str(optional_value, _join_path(path, optional_field))
if value.get("source_event_id") is not None:
scenario["source_event_id"] = _coerce_int(value["source_event_id"], _join_path(path, "source_event_id"))
return scenario
def _normalize_skill_tags(value, path: str) -> list[str]:
if not isinstance(value, list) or not value:
raise ValueError(_field_error("invalid_json_field", path))
tags = [_require_non_empty_str(item, f"{path}[{index}]") for index, item in enumerate(value)]
return list(dict.fromkeys(tags))
def _normalize_tasks(value, default_skill_tag: str, path: str) -> list[dict]:
if not isinstance(value, list):
raise ValueError(_field_error("invalid_json_field", path))
tasks = []
for index, item in enumerate(value):
item_path = f"{path}[{index}]"
if not isinstance(item, dict):
raise ValueError(_field_error("invalid_json_field", item_path))
task_type = _require_non_empty_str(_require_value(item, "task_type", item_path), _join_path(item_path, "task_type"))
prompt = _require_non_empty_str(_require_value(item, "prompt", item_path), _join_path(item_path, "prompt"))
task = {
"task_type": task_type,
"skill_tag": _optional_non_empty_str(item.get("skill_tag"), default_skill_tag, _join_path(item_path, "skill_tag")),
"prompt": prompt,
"required_annotation_types": _optional_list(
item.get("required_annotation_types", []),
_join_path(item_path, "required_annotation_types"),
),
"max_score": _coerce_float(item.get("max_score", 0), _join_path(item_path, "max_score")),
"sort_order": _coerce_int(item.get("sort_order", index), _join_path(item_path, "sort_order")),
"status": _optional_non_empty_str(item.get("status"), "active", _join_path(item_path, "status")),
"meta": _public_task_meta(_optional_dict(item.get("meta", {}), _join_path(item_path, "meta"))),
}
tasks.append(task)
return sorted(tasks, key=lambda task: (task["sort_order"], task["task_type"], task["prompt"]))
def _create_tasks(repository, case_id: int, tasks: list[dict]) -> None:
if not hasattr(repository, "add_task"):
return
for task in tasks:
repository.add_task(
case_id=case_id,
task_type=task["task_type"],
skill_tag=task["skill_tag"],
prompt=task["prompt"],
required_annotation_types=task["required_annotation_types"],
max_score=task["max_score"],
sort_order=task["sort_order"],
status=task["status"],
meta=task["meta"],
)
def _public_import_item(item: dict, case_id: int) -> dict:
public_item = {
"id": case_id,
"title": item["title"],
"scenario": _public_scenario(item["scenario"]),
"skill_tags": item["skill_tags"],
"difficulty": item["difficulty"],
"status": item["status"],
}
if item.get("external_id") is not None:
public_item["external_id"] = item["external_id"]
return public_item
def _public_case_row(row: dict) -> dict:
meta = row.get("meta") if isinstance(row.get("meta"), dict) else {}
title = row.get("title") or meta.get("title") or _default_title(row.get("id"))
public_title = _public_json_value(title)
if public_title is _DROP:
public_title = _default_title(row.get("id"))
skill_tags = _normalize_public_skill_tags(row, meta)
rubric = row.get("rubric")
if rubric in (None, [], {}):
rubric = meta.get("rubric")
tasks = row.get("tasks", meta.get("tasks", []))
item = {
"id": _json_value(row.get("id")),
"title": public_title,
"scenario": _public_scenario(row),
"skill_tags": skill_tags,
"difficulty": _public_json_value(row.get("difficulty") or "medium"),
"status": _public_json_value(row.get("status") or "draft"),
}
public_rubric = _public_json_value(rubric)
if public_rubric not in (_DROP, None, [], {}):
item["rubric"] = public_rubric
public_tasks = _public_json_value(tasks)
if public_tasks not in (_DROP, None, [], {}):
item["tasks"] = public_tasks
public_meta = _public_metadata(meta)
if public_meta:
item["metadata"] = public_meta
return item
def _public_scenario(source: dict) -> dict:
scenario = {
"instrument_id": _json_value(source.get("instrument_id")),
"timeframe": _public_json_value(source.get("timeframe")),
"case_type": _public_json_value(source.get("case_type")),
"start_ts": _json_value(source.get("start_ts")),
"decision_ts": _json_value(source.get("decision_ts")),
"end_ts": _json_value(source.get("end_ts")),
"source_event_refs": _public_json_value(source.get("source_event_refs", [])),
}
for optional_field in ("source_event_type", "source_event_id", "chapter_ref"):
if source.get(optional_field) is not None:
value = _public_json_value(source.get(optional_field))
if value is not _DROP:
scenario[optional_field] = value
return scenario
def _public_metadata(meta: dict) -> dict:
public_meta = {}
for key, value in sorted(meta.items(), key=lambda item: str(item[0])):
key_text = str(key)
if key_text in _RESERVED_META_FIELDS or _has_restricted_text(key_text):
continue
public_value = _public_json_value(value)
if public_value is not _DROP:
public_meta[key_text] = public_value
return public_meta
def _public_task_meta(meta: dict) -> dict:
public_meta = _public_json_value(meta)
if public_meta is _DROP or not isinstance(public_meta, dict):
return {}
return public_meta
def _normalize_public_skill_tags(row: dict, meta: dict) -> list[str]:
raw_tags = row.get("skill_tags") or meta.get("skill_tags")
if raw_tags is None:
raw_tags = [row.get("skill_tag")] if row.get("skill_tag") else []
if isinstance(raw_tags, str):
raw_tags = [raw_tags]
if not isinstance(raw_tags, list):
return []
tags = []
for item in raw_tags:
if isinstance(item, str) and item:
public_item = _public_json_value(item)
if public_item is not _DROP:
tags.append(public_item)
return sorted(dict.fromkeys(tags))
def _require_value(payload: dict, key: str, path: Optional[str]):
if not isinstance(payload, dict):
raise ValueError(_field_error("invalid_json_field", path or key))
if key not in payload or payload.get(key) is None:
raise ValueError(_field_error("missing_required_json_field", _join_path(path, key)))
return payload[key]
def _require_non_empty_str(value, path: str) -> str:
if not isinstance(value, str) or value == "":
raise ValueError(_field_error("invalid_json_field", path))
return value
def _optional_non_empty_str(value, default: str, path: str) -> str:
if value is None:
return default
return _require_non_empty_str(value, path)
def _require_dict(value, path: str) -> dict:
if not isinstance(value, dict):
raise ValueError(_field_error("invalid_json_field", path))
return value
def _optional_dict(value, path: str) -> dict:
if value is None:
return {}
if not isinstance(value, dict):
raise ValueError(_field_error("invalid_json_field", path))
return value
def _optional_list(value, path: str) -> list:
if value is None:
return []
if not isinstance(value, list):
raise ValueError(_field_error("invalid_json_field", path))
return value
def _require_rubric(value, path: str):
if not isinstance(value, (dict, list)):
raise ValueError(_field_error("invalid_json_field", path))
if value == {} or value == []:
raise ValueError(_field_error("invalid_json_field", path))
return value
def _require_timestamp(value, path: str):
if isinstance(value, datetime):
return value
if isinstance(value, str) and value:
return value
raise ValueError(_field_error("invalid_json_field", path))
def _coerce_int(value, path: str) -> int:
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError(_field_error("invalid_json_field", path)) from exc
def _coerce_float(value, path: str) -> float:
try:
return float(value)
except (TypeError, ValueError) as exc:
raise ValueError(_field_error("invalid_json_field", path)) from exc
def _public_json_value(value):
if isinstance(value, dict):
result = {}
for key, item in sorted(value.items(), key=lambda pair: str(pair[0])):
key_text = str(key)
if key_text in _INTERNAL_PUBLIC_FIELDS or _has_restricted_text(key_text):
continue
public_item = _public_json_value(item)
if public_item is not _DROP:
result[key_text] = public_item
return result
if isinstance(value, list):
result = []
for item in value:
public_item = _public_json_value(item)
if public_item is not _DROP:
result.append(public_item)
return result
if isinstance(value, str) and _has_restricted_text(value):
return _DROP
return _json_value(value)
def _json_value(value):
if isinstance(value, datetime):
return value.isoformat()
if isinstance(value, date):
return value.isoformat()
if isinstance(value, Decimal):
return str(value)
if isinstance(value, dict):
return {str(key): _json_value(item) for key, item in sorted(value.items(), key=lambda pair: str(pair[0]))}
if isinstance(value, list):
return [_json_value(item) for item in value]
return value
def _has_restricted_text(value: str) -> bool:
lower_value = value.lower()
return any(fragment in lower_value for fragment in _RESTRICTED_TEXT_FRAGMENTS)
def _sort_id(value) -> tuple[int, object]:
if value is None:
return (1, "")
try:
return (0, int(value))
except (TypeError, ValueError):
return (0, str(value))
def _default_title(case_id) -> str:
return f"training-case-{case_id}" if case_id is not None else "training-case"
def _join_path(path: Optional[str], key: str) -> str:
return f"{path}.{key}" if path else key
def _field_error(prefix: str, path: str) -> str:
return f"{prefix}:{path}"
@@ -1,6 +1,7 @@
from typing import Optional
from src.repositories.training_case_repository import TrainingCaseRepository
from src.services.training.case_import_export import export_training_cases, import_training_cases
class TrainingCaseService:
@@ -69,6 +70,24 @@ class TrainingCaseService:
) -> list[dict]:
return self.repository.get_progress(user_id=user_id, skill_tag=skill_tag, limit=limit)
def export_cases(
self,
status: Optional[str] = None,
skill_tag: Optional[str] = None,
instrument_id: Optional[int] = None,
limit: int = 100,
) -> dict:
cases = self.repository.list_cases(
status=status,
skill_tag=skill_tag,
instrument_id=instrument_id,
limit=limit,
)
return export_training_cases(cases)
def import_cases(self, payload: dict) -> dict:
return import_training_cases(payload, repository=self.repository)
def _without_answer_key(row: dict) -> dict:
cleaned = dict(row)
@@ -0,0 +1,286 @@
import json
import re
import unittest
from datetime import datetime, timezone
from decimal import Decimal
from pathlib import Path
from src.api.server import route_request
from src.api.training import export_training_case_package, import_training_case_package
from src.services.training import TrainingCaseService
from src.services.training.case_import_export import (
export_training_cases,
import_training_cases,
validate_training_case_payload,
)
class _FakeTrainingRepository:
def __init__(self) -> None:
self.created_cases = []
self.created_tasks = []
self.list_args = None
self._next_case_id = 40
def create_case(self, **kwargs):
self._next_case_id += 1
self.created_cases.append(kwargs)
return self._next_case_id
def add_task(self, **kwargs):
task_id = 200 + len(self.created_tasks)
self.created_tasks.append(dict(kwargs, id=task_id))
return task_id
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": 2,
"instrument_id": 1,
"timeframe": "15m",
"case_type": "fvg",
"skill_tag": "fvg",
"start_ts": datetime(2026, 4, 1, 9, tzinfo=timezone.utc),
"decision_ts": datetime(2026, 4, 1, 10, tzinfo=timezone.utc),
"end_ts": datetime(2026, 4, 1, 11, tzinfo=timezone.utc),
"difficulty": "easy",
"status": "published",
"source_event_refs": [{"kind": "fvg_zone", "id": Decimal("9")}],
"meta": {
"title": "A FVG case",
"skill_tags": ["fvg", "invalidation"],
"rubric": [{"category": "zone", "max_score": Decimal("5.0")}],
},
}
]
def _case_item(title: str = "Liquidity sweep practice") -> dict:
return {
"external_id": "case-liquidity-sweep-1",
"title": title,
"scenario": {
"instrument_id": 1,
"timeframe": "15m",
"case_type": "sweep",
"start_ts": "2026-04-01T09:00:00Z",
"decision_ts": "2026-04-01T10:00:00Z",
"end_ts": "2026-04-01T11:00:00Z",
"source_event_type": "sweep_event",
"source_event_id": 7,
"source_event_refs": [{"kind": "sweep_event", "id": 7}],
},
"skill_tags": ["sweep", "liquidity_pool"],
"difficulty": "medium",
"answer": {"side": "buy_side", "close_back_inside": True},
"rubric": [{"category": "sweep_validity", "max_score": 5}],
"tasks": [
{
"task_type": "multiple_choice",
"skill_tag": "sweep",
"prompt": "Classify the sweep.",
"required_annotation_types": ["multiple_choice"],
"max_score": 5,
}
],
"metadata": {"chapter_ref": "chapter_10"},
}
def _case_package() -> dict:
return {"version": 1, "metadata": {"source": "unit_fixture"}, "items": [_case_item()]}
class TrainingCaseImportExportM5Tests(unittest.TestCase):
def test_valid_payload_can_be_imported_into_repository(self) -> None:
repository = _FakeTrainingRepository()
result = import_training_cases(_case_package(), repository=repository)
self.assertEqual(result["version"], 1)
self.assertEqual(result["imported"], 1)
self.assertEqual(result["case_ids"], [41])
self.assertEqual(len(repository.created_cases), 1)
created = repository.created_cases[0]
self.assertEqual(created["instrument_id"], 1)
self.assertEqual(created["timeframe"], "15m")
self.assertEqual(created["case_type"], "sweep")
self.assertEqual(created["skill_tag"], "sweep")
self.assertEqual(created["difficulty"], "medium")
self.assertEqual(created["chapter_ref"], "chapter_10")
self.assertEqual(created["answer_key"]["answer"]["side"], "buy_side")
self.assertEqual(created["answer_key"]["rubric"][0]["category"], "sweep_validity")
self.assertEqual(repository.created_tasks[0]["case_id"], 41)
serialized = json.dumps(result, sort_keys=True)
self.assertNotIn("answer_key", serialized)
self.assertNotIn("buy_side", serialized)
self.assertNotIn("rubric", result["items"][0])
self.assertNotIn("tasks", result["items"][0])
def test_missing_required_field_returns_clear_value_error(self) -> None:
item = _case_item()
item.pop("title")
with self.assertRaisesRegex(ValueError, "missing_required_json_field:items\\[0\\]\\.title"):
import_training_cases({"version": 1, "items": [item]}, repository=_FakeTrainingRepository())
def test_validate_training_case_payload_rejects_internal_answer_key_field(self) -> None:
item = _case_item()
item["answer_key"] = {"side": "buy_side"}
with self.assertRaisesRegex(ValueError, "invalid_json_field:answer_key"):
validate_training_case_payload(item)
def test_export_training_cases_is_stable_public_json(self) -> None:
cases = [
{
"id": 20,
"instrument_id": 1,
"timeframe": "15m",
"case_type": "sweep",
"skill_tag": "sweep",
"start_ts": "2026-04-02T09:00:00Z",
"decision_ts": "2026-04-02T10:00:00Z",
"end_ts": "2026-04-02T11:00:00Z",
"difficulty": "medium",
"status": "draft",
"answer_key": {"answer": {"side": "sell_side"}, "rubric": [{"category": "validity"}]},
"meta": {"title": "B Sweep case", "skill_tags": ["sweep"], "internal_note": "operator only"},
},
{
"id": 10,
"instrument_id": 1,
"timeframe": "15m",
"case_type": "fvg",
"skill_tag": "fvg",
"start_ts": datetime(2026, 4, 1, 9, tzinfo=timezone.utc),
"decision_ts": datetime(2026, 4, 1, 10, tzinfo=timezone.utc),
"end_ts": datetime(2026, 4, 1, 11, tzinfo=timezone.utc),
"difficulty": "easy",
"status": "published",
"source_event_refs": [{"kind": "fvg_zone", "id": Decimal("9")}],
"meta": {
"title": "A FVG case",
"skill_tags": ["fvg", "invalidation"],
"rubric": [{"category": "zone", "max_score": Decimal("5.0")}],
"api" + "_key": "remove-me",
},
},
]
first = export_training_cases(cases)
second = export_training_cases(list(reversed(cases)))
serialized = json.dumps(first, sort_keys=True)
self.assertEqual(first, second)
self.assertEqual(first["version"], 1)
self.assertEqual(first["metadata"], {"item_count": 2})
self.assertEqual([item["id"] for item in first["items"]], [10, 20])
self.assertEqual(first["items"][0]["title"], "A FVG case")
self.assertEqual(first["items"][0]["scenario"]["start_ts"], "2026-04-01T09:00:00+00:00")
self.assertEqual(first["items"][0]["scenario"]["source_event_refs"][0]["id"], "9")
self.assertNotIn("rubric", first["items"][1])
self.assertNotIn("answer_key", serialized)
self.assertNotIn("sell_side", serialized)
self.assertNotIn("validity", serialized)
self.assertNotIn("remove-me", serialized)
def test_export_does_not_derive_public_rubric_from_answer_key(self) -> None:
exported = export_training_cases(
[
{
"id": 30,
"instrument_id": 1,
"timeframe": "15m",
"case_type": "sweep",
"skill_tag": "sweep",
"start_ts": "2026-04-03T09:00:00Z",
"decision_ts": "2026-04-03T10:00:00Z",
"end_ts": "2026-04-03T11:00:00Z",
"difficulty": "medium",
"status": "draft",
"answer_key": {
"answer": {"side": "sell_side", "entry_model": "internal_only"},
"rubric": [{"category": "answer_key_only", "max_score": 5}],
},
"meta": {"title": "Internal answer case", "skill_tags": ["sweep"]},
}
]
)
serialized = json.dumps(exported, sort_keys=True)
self.assertNotIn("rubric", exported["items"][0])
self.assertNotIn("answer_key", serialized)
self.assertNotIn("sell_side", serialized)
self.assertNotIn("internal_only", serialized)
self.assertNotIn("answer_key_only", serialized)
def test_training_api_import_export_routes_are_public(self) -> None:
repository = _FakeTrainingRepository()
service = TrainingCaseService(repository=repository)
dependencies = {
"training_import_cases": lambda body: import_training_case_package(body, service=service),
"training_export_cases": lambda **kwargs: export_training_case_package(service=service, **kwargs),
}
import_status, import_payload = route_request(
"/training/cases/import",
{},
dependencies=dependencies,
method="POST",
body=_case_package(),
)
export_status, export_payload = route_request(
"/training/cases/export",
{"status": ["published"], "skill_tag": ["fvg"], "instrument_id": ["1"], "limit": ["5"]},
dependencies=dependencies,
)
self.assertEqual(import_status, 201)
self.assertEqual(import_payload["imported"], 1)
self.assertEqual(export_status, 200)
self.assertEqual(export_payload["items"][0]["title"], "A FVG case")
self.assertEqual(
repository.list_args,
{"status": "published", "skill_tag": "fvg", "instrument_id": 1, "limit": 5},
)
combined = json.dumps({"import": import_payload, "export": export_payload}, sort_keys=True)
self.assertNotIn("answer_key", combined)
self.assertNotIn("buy_side", combined)
self.assertNotIn("rubric", import_payload["items"][0])
self.assertNotIn("tasks", import_payload["items"][0])
def test_m5_sources_do_not_add_sensitive_literals_or_trading_writes(self) -> None:
paths = [
Path("src/services/training/case_import_export.py"),
Path("src/api/training.py"),
Path("src/api/server.py"),
Path(__file__),
]
source = "\n".join(path.read_text(encoding="utf-8") for path in paths if path.exists()).lower()
for term in (
"data" + "base",
"post" + "gresql",
"psy" + "copg",
"pass" + "word",
"tok" + "en",
"sec" + "ret",
"api" + "_key",
):
self.assertNotIn(term, source)
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 M5 writes to {table_name}")
if __name__ == "__main__":
unittest.main()