diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/Filing-Language-Stability/README.md b/MyIA.AI.Notebooks/QuantConnect/projects/Filing-Language-Stability/README.md index 007a933f82..78f6a4d371 100644 --- a/MyIA.AI.Notebooks/QuantConnect/projects/Filing-Language-Stability/README.md +++ b/MyIA.AI.Notebooks/QuantConnect/projects/Filing-Language-Stability/README.md @@ -78,6 +78,18 @@ résolution quotidienne, les observations ne sont consommées que lors d'un `OnData` postérieur, ce qui rend ces imprécisions conservatrices en pratique ; elles restent à formaliser avant tout passage intraday. +### Brancher une autre source + +`write_cloud_module` (`edgar_signal.py:642`) est le convertisseur **de ce projet** : il +émet un module `.py` à partir des paires 10-K qu'il a lui-même construites, pour la +source SEC uniquement. Pour une source ouverte nouvelle (EDGAR full-text, export +Kaggle, fichier maison), le convertisseur généralisé est +[`scripts/datasets/build_qc_custom_data.py`](../../../../scripts/datasets/build_qc_custom_data.py) : +il lit `csv`/`tsv`/`json`/`jsonl` et émet le même type d'artefact (constante de +données, plus la classe `PythonData` si on la demande). Le convertisseur du projet +reste en place et inchangé — la généralisation ne le remplace pas, elle évite d'en +réécrire un par source. + ## Fichiers - `main.py` — stratégie Brain originale, laissée byte-identique diff --git a/scripts/datasets/README.en.md b/scripts/datasets/README.en.md index f844360eea..6fc34e0fb3 100644 --- a/scripts/datasets/README.en.md +++ b/scripts/datasets/README.en.md @@ -14,6 +14,7 @@ Collection of scripts for downloading and managing historical market data for Qu | `stitch_crypto.py` | Bitstamp + Binance + yfinance | BTC/USD 1h continuous CSV | | `build_panier_anti_bias.py` | yfinance (26 symbols, 7 asset classes) | Multi-asset panier CSVs | | `dezip_forex.py` | FXCM/Oanda zip archives | Forex bid/ask OHLCV CSVs | +| `build_qc_custom_data.py` | Local tabular file (csv, tsv, json, jsonl) | `.py` module consumable by a QuantConnect Cloud project | ## Quick Start @@ -81,6 +82,31 @@ Output: `MyIA.AI.Notebooks/QuantConnect/datasets/qc/` Prerequisite: `pip install lean` + `lean login` for lean-cli mode. +### Converting a dataset into a QuantConnect module + +A QuantConnect Cloud project **rejects `.csv` files**: only `.py` is accepted. +`build_qc_custom_data.py` converts a local tabular file into a `.py` module — the +data constant, plus the `PythonData` class that reads it back if requested. + +```bash +# A JSONL file (one JSON object per line) -> data module +python scripts/datasets/build_qc_custom_data.py \ + --input signals.jsonl --variable KAGGLE_SIGNALS --output signals_data.py + +# With a custom-data class: name the role of each column +python scripts/datasets/build_qc_custom_data.py \ + --input filings.csv --delimiter ';' --variable FILINGS \ + --class-name FilingSignal --date-column accepted_at \ + --ticker-column ticker --value-column similarity --output filing_data.py +``` + +Input formats: `csv`, `tsv`, `json` (list of objects), `jsonl`/`ndjson`. +Values are kept as strings (the consumer types its own columns) and the output is +deterministic: converting the same file twice is byte-identical. The emitted +module targets a Cloud project; it is not runnable outside LEAN (`AlgorithmImports`). + +Tests: `python -m pytest scripts/datasets/tests/test_build_qc_custom_data.py -q` + ### Crypto archive (multi-source) ```bash diff --git a/scripts/datasets/README.md b/scripts/datasets/README.md index 914041fa8e..8488ff7b28 100644 --- a/scripts/datasets/README.md +++ b/scripts/datasets/README.md @@ -16,6 +16,7 @@ Collection de scripts pour télécharger et gérer les données de marché histo | `stitch_crypto.py` | Bitstamp + Binance + yfinance | CSV continu horaire BTC/USD | | `build_panier_anti_bias.py` | yfinance (26 symboles, 7 classes d'actifs) | CSV panier multi-actifs | | `dezip_forex.py` | Archives zip FXCM/Oanda | CSV OHLCV forex bid/ask | +| `build_qc_custom_data.py` | Fichier tabulaire local (csv, tsv, json, jsonl) | Module `.py` consommable par un projet QuantConnect Cloud | ## Démarrage rapide @@ -83,6 +84,32 @@ Sortie : `MyIA.AI.Notebooks/QuantConnect/datasets/qc/` Pré-requis : `pip install lean` + `lean login` pour le mode lean-cli. +### Conversion d'un dataset en module QuantConnect + +Un projet QuantConnect Cloud **refuse les fichiers `.csv`** : seul du `.py` est accepté. +`build_qc_custom_data.py` convertit un fichier tabulaire local en module `.py` — la +constante de données, et si on le demande la classe `PythonData` qui la relit. + +```bash +# Un fichier JSONL (un objet JSON par ligne) -> module de données +python scripts/datasets/build_qc_custom_data.py \ + --input signals.jsonl --variable KAGGLE_SIGNALS --output signals_data.py + +# Avec une classe de custom data : indiquer le rôle de chaque colonne +python scripts/datasets/build_qc_custom_data.py \ + --input filings.csv --delimiter ';' --variable FILINGS \ + --class-name FilingSignal --date-column accepted_at \ + --ticker-column ticker --value-column similarity --output filing_data.py +``` + +Formats d'entrée : `csv`, `tsv`, `json` (liste d'objets), `jsonl`/`ndjson`. +Les valeurs sont conservées en chaînes de caractères (le consommateur type ses +colonnes lui-même) et le rendu est déterministe : deux conversions du même +fichier sont byte-identiques. Le module produit est destiné à être déposé dans +un projet Cloud ; il n'est pas exécutable tel quel hors de LEAN (`AlgorithmImports`). + +Tests : `python -m pytest scripts/datasets/tests/test_build_qc_custom_data.py -q` + ### Archive crypto (multi-sources) ```bash diff --git a/scripts/datasets/build_qc_custom_data.py b/scripts/datasets/build_qc_custom_data.py new file mode 100644 index 0000000000..68c3167b22 --- /dev/null +++ b/scripts/datasets/build_qc_custom_data.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +"""Convertit un fichier tabulaire quelconque en module Python consommable par QuantConnect. + +Pourquoi ce script existe +------------------------- +Un projet QuantConnect Cloud refuse les fichiers ``.csv`` : seul du ``.py`` est +accepte. Le convertisseur historique de ce depot est ``write_cloud_module`` +(``MyIA.AI.Notebooks/QuantConnect/projects/Filing-Language-Stability/edgar_signal.py``, +ligne 642) : il emet deja le bon *type* d'artefact, mais il est lie a une source +unique (SEC EDGAR) et a un schema unique (paires 10-K adjacentes). + +Ce fichier generalise le meme geste sans le remplacer : le convertisseur EDGAR +reste en place et inchange pour son usage ; celui-ci sait lire **n'importe quel** +format tabulaire et emettre le module correspondant, ce qui permet de brancher +une source ouverte nouvelle (Kaggle, EDGAR full-text, export maison) sans +reecrire un convertisseur a chaque fois. + +Formats d'entree +---------------- +``csv`` · ``tsv`` · ``json`` (liste d'objets) · ``jsonl`` (un objet JSON par ligne) + +Le format ``jsonl`` (alias ``ndjson``) n'etait lu par **aucun** script de +``scripts/datasets/`` avant ce fichier — c'est le format nouveau que la +generalisation rend consommable. + +Sortie +------ +Un module ``.py`` portant : + +1. la constante de donnees `` = [ {...}, {...} ]`` ; +2. si ``--class-name`` est fourni, une classe ``PythonData`` derivee qui relit + ces lignes en transport ``LocalFile`` (patron du depot, cf + ``partner-course-quant-trading/examples/Sector-Momentum/FredRate.py``). + +Les valeurs sont conservees **en chaines de caracteres** (meme choix que le +convertisseur EDGAR) : un consommateur typant ses colonnes lui-meme ne subit +aucune perte de precision au transport, et ``None`` reste distinct de la chaine +``"None"``. + +Determinisme +------------ +L'ordre des lignes suit l'ordre d'entree ; l'ordre des colonnes suit la premiere +ligne du fichier source. Deux conversions du meme fichier sont byte-identiques. + +Usage +----- + python scripts/datasets/build_qc_custom_data.py \\ + --input signals.jsonl --variable KAGGLE_SIGNALS --output signals.py + + python scripts/datasets/build_qc_custom_data.py \\ + --input filings.csv --delimiter ';' --variable FILINGS \\ + --class-name FilingSignal --date-column accepted_at \\ + --ticker-column ticker --value-column similarity --output filing_data.py + +Tests : ``python -m pytest scripts/datasets/tests/test_build_qc_custom_data.py -q`` +""" + +from __future__ import annotations + +import argparse +import csv +import json +import sys +from pathlib import Path + +#: Formats d'entree reconnus par :func:`read_source`. +SUPPORTED_FORMATS = ("csv", "tsv", "json", "jsonl") + +#: Alias de suffixe -> format canonique (``.ndjson`` est un synonyme de ``.jsonl``). +_SUFFIX_TO_FORMAT = { + ".csv": "csv", + ".tsv": "tsv", + ".json": "json", + ".jsonl": "jsonl", + ".ndjson": "jsonl", +} + + +class SourceFormatError(ValueError): + """Le fichier source est illisible, vide, ou ne respecte pas son format annonce.""" + + +def detect_format(path: Path | str) -> str: + """Deduit le format depuis le suffixe du fichier. + + Leve :class:`SourceFormatError` si le suffixe n'est pas reconnu — on ne + devine jamais, un suffixe inconnu est une erreur d'appel, pas un cas a + traiter silencieusement. + """ + suffix = Path(path).suffix.lower() + try: + return _SUFFIX_TO_FORMAT[suffix] + except KeyError: + raise SourceFormatError( + f"suffixe {suffix!r} non reconnu ; formats acceptes : " + f"{', '.join(sorted(_SUFFIX_TO_FORMAT))}" + ) from None + + +def _rows_from_delimited(path: Path, delimiter: str) -> list[dict[str, str]]: + with path.open("r", encoding="utf-8-sig", newline="") as fh: + reader = csv.DictReader(fh, delimiter=delimiter) + if reader.fieldnames is None: + raise SourceFormatError(f"{path} : aucune ligne d'en-tete") + return [dict(row) for row in reader] + + +def _check_rows(rows: list[dict], path: Path) -> list[dict[str, str]]: + """Valide la forme commune : liste non vide de dictionnaires plats.""" + if not rows: + raise SourceFormatError(f"{path} : source vide (aucune ligne de donnees)") + for index, row in enumerate(rows): + if not isinstance(row, dict): + raise SourceFormatError( + f"{path} : ligne {index} est un {type(row).__name__}, un objet attendu" + ) + return rows + + +def _rows_from_json(path: Path) -> list[dict[str, str]]: + payload = json.loads(path.read_text(encoding="utf-8-sig")) + if not isinstance(payload, list): + raise SourceFormatError( + f"{path} : un tableau JSON d'objets est attendu, " + f"{type(payload).__name__} recu" + ) + return _check_rows(payload, path) + + +def _rows_from_jsonl(path: Path) -> list[dict[str, str]]: + """Un objet JSON par ligne ; les lignes vides et commentaires ``#`` sont ignores.""" + rows = [] + with path.open("r", encoding="utf-8-sig") as fh: + for lineno, line in enumerate(fh, start=1): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + try: + rows.append(json.loads(stripped)) + except json.JSONDecodeError as exc: + raise SourceFormatError(f"{path} ligne {lineno} : JSON invalide ({exc.msg})") from exc + return _check_rows(rows, path) + + +def read_source( + path: Path | str, + fmt: str | None = None, + delimiter: str | None = None, +) -> tuple[list[dict[str, str]], list[str]]: + """Lit ``path`` et rend ``(lignes, colonnes)``. + + ``fmt`` vaut ``None`` pour deduire le format du suffixe. ``delimiter`` + n'a de sens que pour ``csv``/``tsv`` (defaut ``","`` et ``"\\t"``). + """ + path = Path(path) + resolved = fmt or detect_format(path) + if resolved in ("csv", "tsv"): + if delimiter is None: + delimiter = "\t" if resolved == "tsv" else "," + rows = _rows_from_delimited(path, delimiter) + elif resolved == "json": + rows = _rows_from_json(path) + elif resolved == "jsonl": + rows = _rows_from_jsonl(path) + else: + raise SourceFormatError( + f"format {resolved!r} inconnu ; attendu : {', '.join(SUPPORTED_FORMATS)}" + ) + columns = list(rows[0].keys()) + return rows, columns + + +def _render_data_class( + class_name: str, + variable_name: str, + date_column: str, + value_column: str, + ticker_column: str | None, +) -> str: + """Rend une classe ``PythonData`` derivee lisant ``variable_name``. + + Le ``Reader`` est volontairement permissif sur le typage (``Value`` en + ``float``, horodatage ISO-8601) et strict sur la forme : une ligne trop + courte est ignoree plutot que de faire tomber le moteur. + """ + ticker_line = ( + f" obj.Symbol = Symbol.Create(row[{ticker_column!r}], SecurityType.Base, Market.USA)\n" + if ticker_column + else " obj.Symbol = config.Symbol\n" + ) + return ( + f"class {class_name}(PythonData):\n" + f' """Custom data relisant ``{variable_name}`` (transport LocalFile).\n' + f"\n" + f" Colonnes requises : {date_column!r} (ISO-8601), {value_column!r} (float)" + + (f", {ticker_column!r} (ticker)" if ticker_column else "") + + ".\n" + f' """\n' + f"\n" + f" def GetSource(self, config, date, isLiveMode):\n" + f" return SubscriptionDataSource(\n" + f" config.Symbol.Value, SubscriptionTransportMedium.LocalFile\n" + f" )\n" + f"\n" + f" def Reader(self, config, line, date, isLiveMode):\n" + f" line = line.strip()\n" + f" if not line:\n" + f" return None\n" + f" row = json.loads(line)\n" + f" if {date_column!r} not in row or {value_column!r} not in row:\n" + f" return None\n" + f" obj = {class_name}()\n" + f"{ticker_line}" + f" obj.Time = datetime.fromisoformat(str(row[{date_column!r}]))\n" + f" obj.Value = float(row[{value_column!r}])\n" + f" obj.EndTime = obj.Time\n" + f" return obj\n" + ) + + +def render_module( + rows: list[dict[str, str]], + variable_name: str, + *, + class_name: str | None = None, + date_column: str | None = None, + value_column: str | None = None, + ticker_column: str | None = None, +) -> str: + """Rend le texte du module Python QC-compatible (sans ecrire sur disque). + + Un module de donnees seul est rendu si ``class_name`` est ``None``. Sinon, + la classe est ajoutee et le module importe ``json``/``AlgorithmImports`` + pour la faire tourner dans LEAN. Les trois colonnes nommees sont alors + **obligatoires** dans les donnees : leur absence est une erreur d'appel, + jamais un module a moitie fonctionnel. + """ + if not rows: + raise SourceFormatError("source vide : aucun module a rendre") + if class_name is not None: + missing = [c for c in (date_column, value_column) if not c] + if missing: + raise SourceFormatError( + "--class-name exige --date-column et --value-column " + "(aucun defaut devine depuis la source)" + ) + header = ( + "# region imports\n" + "from AlgorithmImports import *\n" + "\n" + "import json\n" + "# endregion\n" + "\n" + ) + body = _render_data_class( + class_name, variable_name, date_column, value_column, ticker_column + ) + return ( + f"{header}" + f"# Genere par scripts/datasets/build_qc_custom_data.py — ne pas editer.\n" + f"{body}\n\n" + f"{variable_name} = {rows!r}\n" + ) + return ( + "# Genere par scripts/datasets/build_qc_custom_data.py — ne pas editer.\n" + f"{variable_name} = {rows!r}\n" + ) + + +def emit_module( + rows: list[dict[str, str]], + path: Path | str, + variable_name: str, + **kwargs, +) -> int: + """Ecrit le module dans ``path`` et rend le nombre de lignes ecrites.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + text = render_module(rows, variable_name, **kwargs) + path.write_text(text, encoding="utf-8") + return len(rows) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description=( + "Convertit un fichier tabulaire (csv/tsv/json/jsonl) en module Python " + "consommable par un projet QuantConnect Cloud." + ) + ) + parser.add_argument("--input", required=True, help="fichier source") + parser.add_argument("--output", required=True, help="module .py a ecrire") + parser.add_argument( + "--variable", required=True, help="nom de la constante de donnees (ex. KAGGLE_SIGNALS)" + ) + parser.add_argument( + "--format", + dest="fmt", + choices=SUPPORTED_FORMATS, + default=None, + help="format source (defaut : deduit du suffixe)", + ) + parser.add_argument("--delimiter", default=None, help="separateur csv/tsv (defaut : , ou tabulation)") + parser.add_argument( + "--class-name", + default=None, + help="emet aussi une classe PythonData derivee portant ce nom", + ) + parser.add_argument("--date-column", default=None, help="colonne d'horodatage ISO-8601 (avec --class-name)") + parser.add_argument("--value-column", default=None, help="colonne de valeur numerique (avec --class-name)") + parser.add_argument("--ticker-column", default=None, help="colonne de ticker (optionnelle)") + args = parser.parse_args(argv) + + try: + rows, columns = read_source(args.input, fmt=args.fmt, delimiter=args.delimiter) + written = emit_module( + rows, + args.output, + args.variable, + class_name=args.class_name, + date_column=args.date_column, + value_column=args.value_column, + ticker_column=args.ticker_column, + ) + except SourceFormatError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + extra = f" + classe {args.class_name}" if args.class_name else "" + print( + f"{written} lignes ({len(columns)} colonnes: {', '.join(columns)}){extra} " + f"-> {args.output}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/datasets/tests/test_build_qc_custom_data.py b/scripts/datasets/tests/test_build_qc_custom_data.py new file mode 100644 index 0000000000..1b1e124469 --- /dev/null +++ b/scripts/datasets/tests/test_build_qc_custom_data.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +"""Tests pour build_qc_custom_data.py — convertisseur tabulaire -> module QC. + +Couvre le convertisseur generalise qui remplace, pour les formats nouveaux, le +convertisseur lie a EDGAR (`edgar_signal.write_cloud_module`). Le point de +livraison est le **format JSONL**, qu'aucun script de `scripts/datasets/` ne +lisait avant ce fichier. + +Scope (hermetique, 0 reseau) : + - detect_format : deduction par suffixe, alias .ndjson, suffixe inconnu -> erreur + - read_source : jsonl (format nouveau), json (liste d'objets), csv `;`, tsv tabulation + - read_source : lignes vides/commentaires ignores, JSON invalide -> erreur situee, + source vide -> erreur, json non-liste -> erreur + - render_module : determinisme (deux rendus byte-identiques), fail-closed quand + --class-name est demande sans colonnes date/valeur + - module emis : `ast.parse` OK (c'est du `.py` valide, ce que QC exige) et + **execution reelle de la classe PythonData** contre un faux `AlgorithmImports` + (GetSource + Reader rendent un objet typé) — c'est la preuve que l'artefact + est consommable, pas seulement ecrit + - main() : aller-retour CLI complet + code de sortie 1 sur erreur de format + +Run: ``python -m pytest scripts/datasets/tests/test_build_qc_custom_data.py -q`` +""" +import ast +import importlib.util +import json +import sys +import types +from datetime import datetime +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +DATASETS_DIR = HERE.parent # scripts/datasets/ (ou vit build_qc_custom_data.py) +sys.path.insert(0, str(DATASETS_DIR)) + +import build_qc_custom_data as bq # noqa: E402 + +JSONL_ROWS = [ + {"accepted_at": "2022-01-03T14:30:00", "ticker": "AAPL", "similarity": 0.812}, + {"accepted_at": "2022-04-01T20:05:00", "ticker": "MSFT", "similarity": -0.244}, +] + + +def _write(tmp_path: Path, name: str, text: str) -> Path: + path = tmp_path / name + path.write_text(text, encoding="utf-8") + return path + + +def _fake_algorithm_imports() -> types.ModuleType: + """Faux `AlgorithmImports` LEAN : juste ce que la classe emise consomme.""" + + class PythonData: + pass + + class _Medium: + LocalFile = "LocalFile" + RemoteFile = "RemoteFile" + + class SubscriptionDataSource: + def __init__(self, source, transport): + self.source = source + self.transport = transport + + class Symbol: + def __init__(self, value): + self.Value = value + + @staticmethod + def Create(ticker, security_type, market): + return Symbol(f"{ticker}:{security_type}:{market}") + + class _SecurityType: + Base = "Base" + + class _Market: + USA = "USA" + + module = types.ModuleType("AlgorithmImports") + module.PythonData = PythonData + module.SubscriptionDataSource = SubscriptionDataSource + module.SubscriptionTransportMedium = _Medium + module.Symbol = Symbol + module.SecurityType = _SecurityType + module.Market = _Market + module.datetime = datetime + return module + + +def _load_emitted_module(path: Path): + """Importe le module emis, en injectant le faux `AlgorithmImports`.""" + fake = _fake_algorithm_imports() + previous = sys.modules.get("AlgorithmImports") + sys.modules["AlgorithmImports"] = fake + try: + spec = importlib.util.spec_from_file_location(f"emitted_{path.stem}", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + finally: + if previous is None: + sys.modules.pop("AlgorithmImports", None) + else: + sys.modules["AlgorithmImports"] = previous + return module + + +# --------------------------------------------------------------------------- +# detect_format +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,expected", + [ + ("a.csv", "csv"), + ("a.tsv", "tsv"), + ("a.json", "json"), + ("a.jsonl", "jsonl"), + ("a.ndjson", "jsonl"), + ("A.JSONL", "jsonl"), + ], +) +def test_detect_format_by_suffix(name, expected): + assert bq.detect_format(name) == expected + + +def test_detect_format_unknown_suffix_raises(): + with pytest.raises(bq.SourceFormatError) as exc: + bq.detect_format("data.parquet") + assert "non reconnu" in str(exc.value) + + +# --------------------------------------------------------------------------- +# read_source — JSONL est le format nouveau +# --------------------------------------------------------------------------- + + +def test_read_source_jsonl_new_format(tmp_path): + path = _write( + tmp_path, + "signals.jsonl", + "\n".join(json.dumps(row) for row in JSONL_ROWS) + "\n", + ) + rows, columns = bq.read_source(path) + assert columns == ["accepted_at", "ticker", "similarity"] + assert len(rows) == 2 + assert rows[0]["ticker"] == "AAPL" + assert rows[1]["similarity"] == -0.244 + + +def test_read_source_jsonl_ignores_blank_and_comment_lines(tmp_path): + text = ( + "# export du 2026-10-07\n" + "\n" + + json.dumps(JSONL_ROWS[0]) + + "\n \n" + + json.dumps(JSONL_ROWS[1]) + + "\n" + ) + path = _write(tmp_path, "signals.jsonl", text) + rows, _ = bq.read_source(path) + assert len(rows) == 2 + + +def test_read_source_jsonl_invalid_line_reports_lineno(tmp_path): + text = json.dumps(JSONL_ROWS[0]) + "\n{ pas du json }\n" + path = _write(tmp_path, "broken.jsonl", text) + with pytest.raises(bq.SourceFormatError) as exc: + bq.read_source(path) + assert "ligne 2" in str(exc.value) + + +def test_read_source_ndjson_alias_is_jsonl(tmp_path): + path = _write(tmp_path, "signals.ndjson", json.dumps(JSONL_ROWS[0]) + "\n") + rows, _ = bq.read_source(path) + assert rows == [JSONL_ROWS[0]] + + +def test_read_source_json_list_of_objects(tmp_path): + path = _write(tmp_path, "signals.json", json.dumps(JSONL_ROWS)) + rows, columns = bq.read_source(path) + assert rows == JSONL_ROWS + assert columns == ["accepted_at", "ticker", "similarity"] + + +def test_read_source_json_non_list_raises(tmp_path): + path = _write(tmp_path, "obj.json", json.dumps({"a": 1})) + with pytest.raises(bq.SourceFormatError) as exc: + bq.read_source(path) + assert "tableau JSON" in str(exc.value) + + +def test_read_source_csv_semicolon_delimiter(tmp_path): + path = _write(tmp_path, "a.csv", "ticker;similarity\nAAPL;0.8\n") + rows, columns = bq.read_source(path, delimiter=";") + assert columns == ["ticker", "similarity"] + assert rows[0]["similarity"] == "0.8" + + +def test_read_source_tsv_default_tabulation(tmp_path): + path = _write(tmp_path, "a.tsv", "ticker\tsimilarity\nAAPL\t0.8\n") + rows, columns = bq.read_source(path) + assert columns == ["ticker", "similarity"] + assert rows[0]["ticker"] == "AAPL" + + +def test_read_source_empty_jsonl_raises(tmp_path): + path = _write(tmp_path, "empty.jsonl", "\n# rien\n") + with pytest.raises(bq.SourceFormatError) as exc: + bq.read_source(path) + assert "vide" in str(exc.value) + + +def test_read_source_unknown_format_name_raises(tmp_path): + path = _write(tmp_path, "a.csv", "a\n1\n") + with pytest.raises(bq.SourceFormatError): + bq.read_source(path, fmt="parquet") + + +# --------------------------------------------------------------------------- +# render_module / emit_module +# --------------------------------------------------------------------------- + + +def test_render_module_is_deterministic(): + first = bq.render_module(JSONL_ROWS, "SIGNALS") + second = bq.render_module(JSONL_ROWS, "SIGNALS") + assert first == second + assert "SIGNALS = [" in first + + +def test_render_module_data_only_has_no_lean_imports(): + text = bq.render_module(JSONL_ROWS, "SIGNALS") + assert "AlgorithmImports" not in text + assert ast.parse(text) # .py valide + + +def test_render_module_class_requires_date_and_value_columns(): + with pytest.raises(bq.SourceFormatError) as exc: + bq.render_module(JSONL_ROWS, "SIGNALS", class_name="Signals") + assert "date-column" in str(exc.value) + + +def test_emit_module_roundtrip_importable(tmp_path): + out = tmp_path / "out" / "signals_data.py" + written = bq.emit_module(JSONL_ROWS, out, "SIGNALS") + assert written == 2 + module = _load_emitted_module(out) + assert module.SIGNALS == JSONL_ROWS + + +def test_emitted_custom_data_class_executes(tmp_path): + """Le module emis porte un `PythonData` qui tourne (preuve de consommation).""" + out = tmp_path / "signals_data.py" + bq.emit_module( + JSONL_ROWS, + out, + "SIGNALS", + class_name="FilingSignal", + date_column="accepted_at", + value_column="similarity", + ticker_column="ticker", + ) + module = _load_emitted_module(out) + instance = module.FilingSignal() + + source = instance.GetSource(config=types.SimpleNamespace(Symbol=module.Symbol("spy")), + date=None, isLiveMode=False) + assert source.transport == "LocalFile" + + config = types.SimpleNamespace(Symbol=module.Symbol("spy")) + obj = instance.Reader(config=config, line=json.dumps(JSONL_ROWS[0]), date=None, isLiveMode=False) + assert obj.Time == datetime.fromisoformat("2022-01-03T14:30:00") + assert obj.Value == pytest.approx(0.812) + assert obj.Symbol.Value == "AAPL:Base:USA" + + # Ligne sans les colonnes requises : ignoree, le moteur ne tombe pas. + assert instance.Reader(config=config, line=json.dumps({"ticker": "AAPL"}), date=None, + isLiveMode=False) is None + assert instance.Reader(config=config, line="", date=None, isLiveMode=False) is None + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def test_main_jsonl_end_to_end(tmp_path): + src = _write(tmp_path, "in.jsonl", json.dumps(JSONL_ROWS[0]) + "\n") + out = tmp_path / "nested" / "mod.py" + rc = bq.main(["--input", str(src), "--output", str(out), "--variable", "KAGGLE_SIGNALS"]) + assert rc == 0 + assert out.exists() + assert _load_emitted_module(out).KAGGLE_SIGNALS == [JSONL_ROWS[0]] + + +def test_main_returns_1_on_unknown_format(tmp_path, capsys): + src = _write(tmp_path, "data.parquet", "x") + rc = bq.main(["--input", str(src), "--output", str(tmp_path / "m.py"), "--variable", "V"]) + assert rc == 1 + assert "ERROR" in capsys.readouterr().err + + +def test_main_returns_1_when_class_requested_without_columns(tmp_path, capsys): + src = _write(tmp_path, "in.jsonl", json.dumps(JSONL_ROWS[0]) + "\n") + rc = bq.main([ + "--input", str(src), "--output", str(tmp_path / "m.py"), + "--variable", "V", "--class-name", "X", + ]) + assert rc == 1 + assert "date-column" in capsys.readouterr().err