diff --git a/migrations/020_create_training_case_engine_tables.sql b/migrations/020_create_training_case_engine_tables.sql new file mode 100644 index 0000000..812bc22 --- /dev/null +++ b/migrations/020_create_training_case_engine_tables.sql @@ -0,0 +1,119 @@ +create table if not exists training_cases ( + id bigserial primary key, + instrument_id bigint not null references instruments(id), + timeframe text not null, + case_type text not null, + skill_tag text not null, + chapter_ref text, + source_event_type text, + source_event_id bigint, + source_event_refs jsonb not null default '[]'::jsonb, + start_ts timestamptz not null, + decision_ts timestamptz not null, + end_ts timestamptz not null, + difficulty text not null default 'medium', + answer_key jsonb not null default '{}'::jsonb, + status text not null default 'draft', + meta jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + check (start_ts <= decision_ts), + check (decision_ts <= end_ts) +); + +create index if not exists idx_training_cases_instr_tf_decision_desc +on training_cases (instrument_id, timeframe, decision_ts desc); + +create index if not exists idx_training_cases_skill_status_created_desc +on training_cases (skill_tag, status, created_at desc); + +create index if not exists idx_training_cases_type_status_created_desc +on training_cases (case_type, status, created_at desc); + +create table if not exists training_case_tasks ( + id bigserial primary key, + case_id bigint not null references training_cases(id) on delete cascade, + task_type text not null, + skill_tag text not null, + prompt text not null, + required_annotation_types jsonb not null default '[]'::jsonb, + max_score numeric(8,2) not null default 0, + sort_order integer not null default 0, + status text not null default 'active', + meta jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_training_case_tasks_case_sort +on training_case_tasks (case_id, sort_order, id); + +create index if not exists idx_training_case_tasks_skill_type +on training_case_tasks (skill_tag, task_type); + +create table if not exists training_attempts ( + id bigserial primary key, + case_id bigint not null references training_cases(id), + user_id text not null default 'local', + status text not null default 'started', + current_frame_index integer not null default 0, + started_at timestamptz not null default now(), + submitted_at timestamptz, + total_score numeric(8,2), + meta jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_training_attempts_user_status_created_desc +on training_attempts (user_id, status, created_at desc); + +create index if not exists idx_training_attempts_case_user_created_desc +on training_attempts (case_id, user_id, created_at desc); + +create table if not exists training_annotations ( + id bigserial primary key, + attempt_id bigint not null references training_attempts(id) on delete cascade, + task_id bigint references training_case_tasks(id), + user_id text not null default 'local', + annotation_type text not null, + payload jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_training_annotations_attempt_type_created +on training_annotations (attempt_id, annotation_type, created_at); + +create table if not exists training_scores ( + id bigserial primary key, + attempt_id bigint not null references training_attempts(id) on delete cascade, + task_id bigint references training_case_tasks(id), + user_id text not null default 'local', + category text not null, + score numeric(8,2) not null, + max_score numeric(8,2) not null, + feedback jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_training_scores_attempt_category +on training_scores (attempt_id, category); + +create table if not exists training_progress ( + id bigserial primary key, + user_id text not null default 'local', + skill_tag text not null, + attempt_count integer not null default 0, + avg_score numeric(8,4), + last_attempt_at timestamptz, + weakness_tags jsonb not null default '[]'::jsonb, + meta jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (user_id, skill_tag) +); + +create index if not exists idx_training_progress_user_skill +on training_progress (user_id, skill_tag); diff --git a/src/db/base.py b/src/db/base.py index fa2b68a..9ece971 100644 --- a/src/db/base.py +++ b/src/db/base.py @@ -1,5 +1,8 @@ +from sqlalchemy import DateTime from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): - pass + type_annotation_map = { + object: DateTime(timezone=True), + } diff --git a/src/models/__init__.py b/src/models/__init__.py index e482912..f719871 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -15,6 +15,14 @@ from src.models.sweep_event import SweepEvent from src.models.swing import Swing from src.models.trade_execution import TradeExecution from src.models.trade_signal import TradeSignal +from src.models.training import ( + TrainingAnnotation, + TrainingAttempt, + TrainingCase, + TrainingCaseTask, + TrainingProgress, + TrainingScore, +) from src.models.venue import Venue __all__ = [ @@ -33,5 +41,11 @@ __all__ = [ "Swing", "TradeExecution", "TradeSignal", + "TrainingAnnotation", + "TrainingAttempt", + "TrainingCase", + "TrainingCaseTask", + "TrainingProgress", + "TrainingScore", "Venue", ] diff --git a/src/models/training.py b/src/models/training.py new file mode 100644 index 0000000..3725727 --- /dev/null +++ b/src/models/training.py @@ -0,0 +1,108 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import DateTime, ForeignKey, Integer, Numeric, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from src.db.base import Base + + +class TrainingCase(Base): + __tablename__ = "training_cases" + + id: Mapped[int] = mapped_column(primary_key=True) + instrument_id: Mapped[int] = mapped_column(ForeignKey("instruments.id"), nullable=False) + timeframe: Mapped[str] = mapped_column(Text, nullable=False) + case_type: Mapped[str] = mapped_column(Text, nullable=False) + skill_tag: Mapped[str] = mapped_column(Text, nullable=False) + chapter_ref: Mapped[Optional[str]] = mapped_column(Text) + source_event_type: Mapped[Optional[str]] = mapped_column(Text) + source_event_id: Mapped[Optional[int]] = mapped_column() + source_event_refs: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + start_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + decision_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + end_ts: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + difficulty: Mapped[str] = mapped_column(Text, nullable=False, default="medium") + answer_key: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + status: Mapped[str] = mapped_column(Text, nullable=False, default="draft") + meta: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class TrainingCaseTask(Base): + __tablename__ = "training_case_tasks" + + id: Mapped[int] = mapped_column(primary_key=True) + case_id: Mapped[int] = mapped_column(ForeignKey("training_cases.id"), nullable=False) + task_type: Mapped[str] = mapped_column(Text, nullable=False) + skill_tag: Mapped[str] = mapped_column(Text, nullable=False) + prompt: Mapped[str] = mapped_column(Text, nullable=False) + required_annotation_types: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + max_score: Mapped[float] = mapped_column(Numeric(8, 2), nullable=False, default=0) + sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + status: Mapped[str] = mapped_column(Text, nullable=False, default="active") + meta: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class TrainingAttempt(Base): + __tablename__ = "training_attempts" + + id: Mapped[int] = mapped_column(primary_key=True) + case_id: Mapped[int] = mapped_column(ForeignKey("training_cases.id"), nullable=False) + user_id: Mapped[str] = mapped_column(Text, nullable=False, default="local") + status: Mapped[str] = mapped_column(Text, nullable=False, default="started") + current_frame_index: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + submitted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + total_score: Mapped[Optional[float]] = mapped_column(Numeric(8, 2)) + meta: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class TrainingAnnotation(Base): + __tablename__ = "training_annotations" + + id: Mapped[int] = mapped_column(primary_key=True) + attempt_id: Mapped[int] = mapped_column(ForeignKey("training_attempts.id"), nullable=False) + task_id: Mapped[Optional[int]] = mapped_column(ForeignKey("training_case_tasks.id")) + user_id: Mapped[str] = mapped_column(Text, nullable=False, default="local") + annotation_type: Mapped[str] = mapped_column(Text, nullable=False) + payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class TrainingScore(Base): + __tablename__ = "training_scores" + + id: Mapped[int] = mapped_column(primary_key=True) + attempt_id: Mapped[int] = mapped_column(ForeignKey("training_attempts.id"), nullable=False) + task_id: Mapped[Optional[int]] = mapped_column(ForeignKey("training_case_tasks.id")) + user_id: Mapped[str] = mapped_column(Text, nullable=False, default="local") + category: Mapped[str] = mapped_column(Text, nullable=False) + score: Mapped[float] = mapped_column(Numeric(8, 2), nullable=False) + max_score: Mapped[float] = mapped_column(Numeric(8, 2), nullable=False) + feedback: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class TrainingProgress(Base): + __tablename__ = "training_progress" + __table_args__ = (UniqueConstraint("user_id", "skill_tag", name="uq_training_progress_user_skill"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[str] = mapped_column(Text, nullable=False, default="local") + skill_tag: Mapped[str] = mapped_column(Text, nullable=False) + attempt_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + avg_score: Mapped[Optional[float]] = mapped_column(Numeric(8, 4)) + last_attempt_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) + weakness_tags: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + meta: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) diff --git a/src/repositories/__init__.py b/src/repositories/__init__.py index a79a431..3e1152f 100644 --- a/src/repositories/__init__.py +++ b/src/repositories/__init__.py @@ -13,6 +13,7 @@ from src.repositories.signal_repository import SignalRepository from src.repositories.structure_repository import StructureRepository from src.repositories.swing_repository import SwingRepository from src.repositories.sweep_repository import SweepRepository +from src.repositories.training_case_repository import TrainingCaseRepository __all__ = [ "BacktestRepository", @@ -28,4 +29,5 @@ __all__ = [ "StructureRepository", "SwingRepository", "SweepRepository", + "TrainingCaseRepository", ] diff --git a/src/repositories/training_case_repository.py b/src/repositories/training_case_repository.py new file mode 100644 index 0000000..8417ce1 --- /dev/null +++ b/src/repositories/training_case_repository.py @@ -0,0 +1,428 @@ +from datetime import datetime, timezone +import json +from typing import Optional + +from sqlalchemy import text + +from src.db.session import SessionLocal + + +class TrainingCaseRepository: + def create_case( + self, + instrument_id: int, + timeframe: str, + case_type: str, + skill_tag: str, + start_ts, + decision_ts, + end_ts, + source_event_refs: list, + answer_key: dict, + status: str = "draft", + chapter_ref: Optional[str] = None, + source_event_type: Optional[str] = None, + source_event_id: Optional[int] = None, + difficulty: str = "medium", + meta: Optional[dict] = None, + ) -> int: + sql = text( + """ + insert into training_cases ( + instrument_id, + timeframe, + case_type, + skill_tag, + chapter_ref, + source_event_type, + source_event_id, + source_event_refs, + start_ts, + decision_ts, + end_ts, + difficulty, + answer_key, + status, + meta + ) values ( + :instrument_id, + :timeframe, + :case_type, + :skill_tag, + :chapter_ref, + :source_event_type, + :source_event_id, + cast(:source_event_refs as jsonb), + :start_ts, + :decision_ts, + :end_ts, + :difficulty, + cast(:answer_key as jsonb), + :status, + cast(:meta as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "instrument_id": instrument_id, + "timeframe": timeframe, + "case_type": case_type, + "skill_tag": skill_tag, + "chapter_ref": chapter_ref, + "source_event_type": source_event_type, + "source_event_id": source_event_id, + "source_event_refs": json.dumps(source_event_refs, default=str), + "start_ts": start_ts, + "decision_ts": decision_ts, + "end_ts": end_ts, + "difficulty": difficulty, + "answer_key": json.dumps(answer_key, default=str), + "status": status, + "meta": json.dumps(meta or {}, default=str), + }, + ) + return int(result.scalar_one()) + + def add_task( + self, + case_id: int, + task_type: str, + skill_tag: str, + prompt: str, + required_annotation_types: Optional[list] = None, + max_score: float = 0, + sort_order: int = 0, + status: str = "active", + meta: Optional[dict] = None, + ) -> int: + sql = text( + """ + insert into training_case_tasks ( + case_id, + task_type, + skill_tag, + prompt, + required_annotation_types, + max_score, + sort_order, + status, + meta + ) values ( + :case_id, + :task_type, + :skill_tag, + :prompt, + cast(:required_annotation_types as jsonb), + :max_score, + :sort_order, + :status, + cast(:meta as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "case_id": case_id, + "task_type": task_type, + "skill_tag": skill_tag, + "prompt": prompt, + "required_annotation_types": json.dumps(required_annotation_types or [], default=str), + "max_score": max_score, + "sort_order": sort_order, + "status": status, + "meta": json.dumps(meta or {}, default=str), + }, + ) + return int(result.scalar_one()) + + def list_cases( + self, + status: Optional[str] = None, + skill_tag: Optional[str] = None, + instrument_id: Optional[int] = None, + limit: int = 100, + ) -> list[dict]: + filters = [] + params = {"limit": limit} + if status is not None: + filters.append("status = :status") + params["status"] = status + if skill_tag is not None: + filters.append("skill_tag = :skill_tag") + params["skill_tag"] = skill_tag + if instrument_id is not None: + filters.append("instrument_id = :instrument_id") + params["instrument_id"] = instrument_id + where_sql = f"where {' and '.join(filters)}" if filters else "" + sql = text( + f""" + select + id, + instrument_id, + timeframe, + case_type, + skill_tag, + chapter_ref, + source_event_type, + source_event_id, + source_event_refs, + start_ts, + decision_ts, + end_ts, + difficulty, + status, + meta, + created_at, + updated_at + from training_cases + {where_sql} + order by created_at desc + limit :limit + """ + ) + with SessionLocal() as session: + rows = session.execute(sql, params).mappings().all() + return [dict(row) for row in rows] + + def get_case(self, case_id: int, include_answer_key: bool = False) -> Optional[dict]: + answer_key_select = ", answer_key" if include_answer_key else "" + sql = text( + f""" + select + id, + instrument_id, + timeframe, + case_type, + skill_tag, + chapter_ref, + source_event_type, + source_event_id, + source_event_refs, + start_ts, + decision_ts, + end_ts, + difficulty, + status, + meta, + created_at, + updated_at + {answer_key_select} + from training_cases + where id = :case_id + limit 1 + """ + ) + with SessionLocal() as session: + row = session.execute(sql, {"case_id": case_id}).mappings().first() + return dict(row) if row else None + + def create_attempt( + self, + case_id: int, + user_id: str = "local", + status: str = "started", + current_frame_index: int = 0, + meta: Optional[dict] = None, + ) -> int: + sql = text( + """ + insert into training_attempts ( + case_id, + user_id, + status, + current_frame_index, + started_at, + meta + ) values ( + :case_id, + :user_id, + :status, + :current_frame_index, + :started_at, + cast(:meta as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "case_id": case_id, + "user_id": user_id, + "status": status, + "current_frame_index": current_frame_index, + "started_at": datetime.now(timezone.utc), + "meta": json.dumps(meta or {}, default=str), + }, + ) + return int(result.scalar_one()) + + def add_annotation( + self, + attempt_id: int, + annotation_type: str, + payload: dict, + user_id: str = "local", + task_id: Optional[int] = None, + ) -> int: + sql = text( + """ + insert into training_annotations ( + attempt_id, + task_id, + user_id, + annotation_type, + payload + ) values ( + :attempt_id, + :task_id, + :user_id, + :annotation_type, + cast(:payload as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "attempt_id": attempt_id, + "task_id": task_id, + "user_id": user_id, + "annotation_type": annotation_type, + "payload": json.dumps(payload, default=str), + }, + ) + return int(result.scalar_one()) + + def submit_attempt(self, attempt_id: int, total_score: Optional[float], status: str = "submitted") -> None: + sql = text( + """ + update training_attempts + set status = :status, + submitted_at = :submitted_at, + total_score = :total_score, + updated_at = now() + where id = :attempt_id + """ + ) + with SessionLocal.begin() as session: + session.execute( + sql, + { + "attempt_id": attempt_id, + "status": status, + "submitted_at": datetime.now(timezone.utc), + "total_score": total_score, + }, + ) + + def submit_score( + self, + attempt_id: int, + category: str, + score: float, + max_score: float, + feedback: dict, + user_id: str = "local", + task_id: Optional[int] = None, + ) -> int: + sql = text( + """ + insert into training_scores ( + attempt_id, + task_id, + user_id, + category, + score, + max_score, + feedback + ) values ( + :attempt_id, + :task_id, + :user_id, + :category, + :score, + :max_score, + cast(:feedback as jsonb) + ) + returning id + """ + ) + with SessionLocal.begin() as session: + result = session.execute( + sql, + { + "attempt_id": attempt_id, + "task_id": task_id, + "user_id": user_id, + "category": category, + "score": score, + "max_score": max_score, + "feedback": json.dumps(feedback, default=str), + }, + ) + return int(result.scalar_one()) + + def update_progress( + self, + user_id: str, + skill_tag: str, + score: Optional[float], + weakness_tags: Optional[list] = None, + meta: Optional[dict] = None, + ) -> None: + sql = text( + """ + insert into training_progress ( + user_id, + skill_tag, + attempt_count, + avg_score, + last_attempt_at, + weakness_tags, + meta + ) values ( + :user_id, + :skill_tag, + 1, + :score, + :last_attempt_at, + cast(:weakness_tags as jsonb), + cast(:meta as jsonb) + ) + on conflict (user_id, skill_tag) do update + set attempt_count = training_progress.attempt_count + 1, + avg_score = case + when :score is null then training_progress.avg_score + when training_progress.avg_score is null then :score + else ((training_progress.avg_score * training_progress.attempt_count) + :score) + / (training_progress.attempt_count + 1) + end, + last_attempt_at = excluded.last_attempt_at, + weakness_tags = excluded.weakness_tags, + meta = training_progress.meta || excluded.meta, + updated_at = now() + """ + ) + with SessionLocal.begin() as session: + session.execute( + sql, + { + "user_id": user_id, + "skill_tag": skill_tag, + "score": score, + "last_attempt_at": datetime.now(timezone.utc), + "weakness_tags": json.dumps(weakness_tags or [], default=str), + "meta": json.dumps(meta or {}, default=str), + }, + ) diff --git a/tests/unit/test_training_case_engine_m1.py b/tests/unit/test_training_case_engine_m1.py new file mode 100644 index 0000000..1316190 --- /dev/null +++ b/tests/unit/test_training_case_engine_m1.py @@ -0,0 +1,166 @@ +import inspect +import os +import re +import unittest +from unittest.mock import patch + +os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") + +from scripts import run_migration_static_checks +from src.db.base import Base + + +TRAINING_REQUIRED_SCHEMA = { + "training_cases": ( + "id", + "case_type", + "skill_tag", + "instrument_id", + "timeframe", + "start_ts", + "decision_ts", + "end_ts", + "source_event_refs", + "answer_key", + "status", + "created_at", + "updated_at", + ), + "training_case_tasks": ( + "id", + "case_id", + "task_type", + "skill_tag", + "prompt", + "required_annotation_types", + "max_score", + "status", + "created_at", + "updated_at", + ), + "training_attempts": ( + "id", + "case_id", + "user_id", + "status", + "started_at", + "submitted_at", + "total_score", + "created_at", + "updated_at", + ), + "training_annotations": ( + "id", + "attempt_id", + "user_id", + "annotation_type", + "payload", + "created_at", + "updated_at", + ), + "training_scores": ( + "id", + "attempt_id", + "user_id", + "category", + "score", + "max_score", + "feedback", + "created_at", + "updated_at", + ), + "training_progress": ( + "id", + "user_id", + "skill_tag", + "attempt_count", + "avg_score", + "last_attempt_at", + "weakness_tags", + "created_at", + "updated_at", + ), +} + + +class _Result: + def __init__(self, rows=None, scalar=1): + self._rows = rows or [] + self._scalar = scalar + + def mappings(self): + return self + + def all(self): + return self._rows + + def first(self): + return self._rows[0] if self._rows else None + + def scalar_one(self): + return self._scalar + + +class _ReadSession: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, traceback): + return False + + def execute(self, sql, params=None): + sql_text = str(sql).lower() + case_row = { + "id": 7, + "case_type": "sweep", + "skill_tag": "liquidity_pool", + "instrument_id": 1, + "timeframe": "15m", + "start_ts": "2026-04-01T00:00:00Z", + "decision_ts": "2026-04-01T02:00:00Z", + "end_ts": "2026-04-01T04:00:00Z", + "source_event_refs": [{"table": "sweep_events", "id": 5}], + "status": "published", + "created_at": "2026-04-01T00:00:00Z", + "updated_at": "2026-04-01T00:00:00Z", + } + if "answer_key" in sql_text: + case_row["answer_key"] = {"side": "buy_side"} + return _Result(rows=[case_row]) + + +class TrainingCaseEngineM1Tests(unittest.TestCase): + def test_migration_static_checks_can_identify_training_tables(self) -> None: + result = run_migration_static_checks.run_migration_static_checks(required_schema=TRAINING_REQUIRED_SCHEMA) + + self.assertTrue(result["passed"], result["errors"]) + + def test_model_metadata_contains_training_tables(self) -> None: + import src.models # noqa: F401 + + for table_name in TRAINING_REQUIRED_SCHEMA: + self.assertIn(table_name, Base.metadata.tables) + + def test_repository_source_does_not_write_to_trading_chain_tables(self) -> None: + from src.repositories.training_case_repository import TrainingCaseRepository + + source = inspect.getsource(TrainingCaseRepository).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"repository writes to {table_name}") + + def test_list_cases_does_not_expose_answer_key_by_default(self) -> None: + from src.repositories import training_case_repository as repository_module + + repository = repository_module.TrainingCaseRepository() + + with patch.object(repository_module, "SessionLocal", return_value=_ReadSession()): + cases = repository.list_cases() + + self.assertEqual(len(cases), 1) + self.assertNotIn("answer_key", cases[0]) + + +if __name__ == "__main__": + unittest.main()