This commit is contained in:
2026-10-07 21:23:21 +08:00
parent 79b02d1c7d
commit 9c8e9d523a
11 changed files with 557 additions and 53 deletions

View File

@@ -2,13 +2,15 @@ from __future__ import annotations
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from unittest.mock import MagicMock, patch
import numpy as np
import pandas as pd
import yaml
from cbpred.pipeline import (
CSV_COLUMNS,
@@ -25,6 +27,9 @@ from cbpred.pipeline import (
run_training,
Symbol,
run_banbot_download,
detect_db_type,
effective_banbot_config,
prepare_data,
)
@@ -44,6 +49,99 @@ def make_hour_rows(start_ms: int, hours: int, phase: float = 0.0):
class PipelineTests(unittest.TestCase):
def test_download_reads_utf8_stderr_on_windows(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
downloader = root / "downloader.py"
downloader.write_text(
"import sys\n"
"sys.stderr.buffer.write('\\u4e00'.encode('utf-8'))\n",
encoding="utf-8",
)
symbol = Symbol(1, "binance", "linear", "BTC/USDT:USDT", 1, 0)
downloaded, unavailable = run_banbot_download(
[symbol], root, root / "config.yml", 1, 2,
banbot_cmd=f'"{sys.executable}" "{downloader}"',
)
self.assertEqual(downloaded, [symbol])
self.assertEqual(unavailable, [])
def test_auto_database_prefers_timescale_when_both_tables_exist(self):
conn = MagicMock()
cur = conn.cursor.return_value.__enter__.return_value
cur.fetchone.return_value = ("exsymbol", "exsymbol_q")
self.assertEqual(detect_db_type(conn), "timescale")
cur.execute.assert_called_once_with("SELECT to_regclass('exsymbol'), to_regclass('exsymbol_q')")
def test_auto_database_detects_questdb_postgres_tables(self):
conn = MagicMock()
conn.cursor.return_value.__enter__.return_value.fetchone.return_value = (None, "exsymbol_q")
self.assertEqual(detect_db_type(conn), "questdb")
def test_auto_database_falls_back_for_questdb_without_to_regclass(self):
conn = MagicMock()
cur = conn.cursor.return_value.__enter__.return_value
cur.execute.side_effect = [
RuntimeError("unknown function: to_regclass"),
RuntimeError("table does not exist [table=exsymbol]"),
None,
]
self.assertEqual(detect_db_type(conn), "questdb")
self.assertEqual(conn.rollback.call_count, 2)
def test_auto_database_does_not_hide_permission_errors(self):
conn = MagicMock()
conn.cursor.return_value.__enter__.return_value.execute.side_effect = RuntimeError("permission denied")
with self.assertRaisesRegex(RuntimeError, "permission denied"):
detect_db_type(conn)
def test_effective_config_is_removed_after_failure_and_original_unchanged(self):
with tempfile.TemporaryDirectory() as directory:
config_path = Path(directory) / "config.yml"
original = "database:\n url: original\n db_type: questdb\nassets: ./assets\n"
config_path.write_text(original)
with self.assertRaisesRegex(RuntimeError, "failed"):
with effective_banbot_config(config_path, "selected", "timescale") as effective:
self.assertEqual(effective.parent, config_path.parent)
self.assertTrue(effective.exists())
raise RuntimeError("failed")
self.assertFalse(effective.exists())
self.assertEqual(config_path.read_text(), original)
def test_prepare_propagates_selected_database_to_download_and_export(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config_path = root / "config.yml"
original = "database:\n url: original\n db_type: questdb\n max_conn: 4\nmarket_type: spot\nassets: ./assets\n"
config_path.write_text(original)
symbol = Symbol(1, "binance", "linear", "BTC/USDT:USDT", 1, 0)
commands = []
config_paths = []
def run(command, **kwargs):
commands.append(command)
path = Path(command[command.index("--config") + 1])
config_paths.append(path)
effective = yaml.safe_load(path.read_text(encoding="utf-8"))
self.assertEqual(effective["database"], {"url": "selected-secret", "db_type": "timescale", "max_conn": 4})
self.assertEqual(effective["assets"], "./assets")
self.assertEqual(effective["market_type"], "linear")
self.assertEqual(path.parent, config_path.parent)
self.assertNotIn("selected-secret", command)
conn = MagicMock()
with patch("cbpred.pipeline.connect_database", return_value=conn) as connect, \
patch("cbpred.pipeline.detect_db_type", return_value="timescale"), \
patch("cbpred.pipeline.fetch_symbols", return_value=([symbol], [])), \
patch("cbpred.pipeline.subprocess.run", side_effect=run):
manifest = prepare_data(root / "data", root, config_path, dsn="selected-secret", end_ms=EIGHT_H_MS * 1000)
connect.assert_called_once_with("selected-secret")
self.assertEqual(len(commands), 2)
self.assertEqual(config_paths[0], config_paths[1])
self.assertFalse(config_paths[0].exists())
self.assertEqual(config_path.read_text(), original)
self.assertEqual(manifest["db_type"], "timescale")
def test_download_skips_invalid_current_symbol_and_retries_chunk(self):
valid = Symbol(1, "binance", "linear", "BTC/USDT:USDT", 1, 0)
stale = Symbol(2, "binance", "linear", "SXP/USDT:USDT", 1, 0)