diff --git a/docker/test_deploy.sh b/docker/test_deploy.sh new file mode 100755 index 00000000..65f63ec8 --- /dev/null +++ b/docker/test_deploy.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# ============================================================================= +# TradeCat Docker 部署验证脚本 +# 用法: ./test_deploy.sh [--build] [--cleanup] +# ============================================================================= + +set -e + +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +NC='\033[0m' + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +log_info() { echo -e "${GREEN}[PASS]${NC} $1"; } +log_fail() { echo -e "${RED}[FAIL]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } + +TESTS_PASSED=0 +TESTS_FAILED=0 + +test_case() { + local name="$1" + local cmd="$2" + local expect_fail="${3:-false}" + + echo -n "Testing: $name... " + + if eval "$cmd" > /dev/null 2>&1; then + if [ "$expect_fail" = "true" ]; then + log_fail "Expected failure but succeeded" + TESTS_FAILED=$((TESTS_FAILED + 1)) + else + log_info "OK" + TESTS_PASSED=$((TESTS_PASSED + 1)) + fi + else + if [ "$expect_fail" = "true" ]; then + log_info "OK (expected failure)" + TESTS_PASSED=$((TESTS_PASSED + 1)) + else + log_fail "Failed" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi + fi +} + +# ============================================================================= +# 静态检查 +# ============================================================================= +echo "=== 静态检查 ===" + +test_case "Dockerfile 存在" "[ -f '$SCRIPT_DIR/Dockerfile' ]" +test_case "docker-compose.yml 存在" "[ -f '$SCRIPT_DIR/docker-compose.yml' ]" +test_case "entrypoint.sh 存在且可执行" "[ -x '$SCRIPT_DIR/entrypoint.sh' ]" +test_case "SQL 初始化脚本存在" "[ -d '$SCRIPT_DIR/timescaledb' ] && [ -f '$SCRIPT_DIR/timescaledb/001_init.sql' ]" +test_case "config/.env.example 存在" "[ -f '$PROJECT_ROOT/config/.env.example' ]" + +# Bash 语法检查 +test_case "entrypoint.sh 语法正确" "bash -n '$SCRIPT_DIR/entrypoint.sh'" + +# SQL 语法检查(基本) +test_case "SQL 文件包含 CREATE 语句" "grep -c 'CREATE' '$SCRIPT_DIR/timescaledb/'*.sql | grep -v ':0' | wc -l | grep -q '^4$'" + +# ============================================================================= +# .env 解析测试 +# ============================================================================= +echo "" +echo "=== .env 解析测试 ===" + +# 创建临时测试文件 +TEST_ENV=$(mktemp) +cat > "$TEST_ENV" << 'EOF' +BOT_TOKEN=123456:ABC-DEF +DATABASE_URL=postgresql://user:p@ss=word@host:5432/db +# 注释行 +SYMBOLS_GROUPS=main4 + HTTP_PROXY = http://127.0.0.1:7890 +INVALID_KEY=should_not_load +EOF + +# 模拟解析 +test_case ".env 解析 - 基本键值" "grep -q 'BOT_TOKEN=123456:ABC-DEF' '$TEST_ENV'" +test_case ".env 解析 - 含特殊字符的值" "grep 'DATABASE_URL' '$TEST_ENV' | grep -q 'p@ss=word'" +rm -f "$TEST_ENV" + +# ============================================================================= +# 依赖文件检查 +# ============================================================================= +echo "" +echo "=== 依赖文件检查 ===" + +for svc in data-service trading-service telegram-service ai-service; do + test_case "$svc/requirements.txt 存在" "[ -f '$PROJECT_ROOT/services/$svc/requirements.txt' ]" +done + +# ============================================================================= +# Docker 构建测试(可选) +# ============================================================================= +if [ "$1" = "--build" ]; then + echo "" + echo "=== Docker 构建测试 ===" + + if command -v docker &> /dev/null; then + test_case "Docker 可用" "docker info > /dev/null 2>&1" + + cd "$SCRIPT_DIR" + + # 构建镜像 + echo "构建镜像中(这可能需要几分钟)..." + if docker build -t tradecat-test -f Dockerfile .. > /tmp/docker_build.log 2>&1; then + log_info "镜像构建成功" + TESTS_PASSED=$((TESTS_PASSED + 1)) + + # 测试镜像 + test_case "镜像可启动" "docker run --rm tradecat-test echo 'OK'" + test_case "Python 可用" "docker run --rm tradecat-test python --version" + test_case "TA-Lib 已安装" "docker run --rm tradecat-test python -c 'import talib'" + test_case "psycopg 已安装" "docker run --rm tradecat-test python -c 'import psycopg'" + + # 清理 + if [ "$2" = "--cleanup" ] || [ "$1" = "--cleanup" ]; then + docker rmi tradecat-test > /dev/null 2>&1 || true + fi + else + log_fail "镜像构建失败,查看 /tmp/docker_build.log" + TESTS_FAILED=$((TESTS_FAILED + 1)) + fi + else + log_warn "Docker 不可用,跳过构建测试" + fi +fi + +# ============================================================================= +# 总结 +# ============================================================================= +echo "" +echo "==========================================" +echo "测试结果: $TESTS_PASSED 通过, $TESTS_FAILED 失败" +echo "==========================================" + +if [ $TESTS_FAILED -gt 0 ]; then + exit 1 +fi diff --git a/services-preview/vis-service/src/templates/registry.py b/services-preview/vis-service/src/templates/registry.py index 699eae90..0152c76b 100644 --- a/services-preview/vis-service/src/templates/registry.py +++ b/services-preview/vis-service/src/templates/registry.py @@ -1084,6 +1084,212 @@ def price_to_fig_x(price): return _fig_to_png(fig), "image/png" +def render_bb_zone_strip(params: Dict, output: str) -> Tuple[object, str]: + """ + 全市场布林带分布图 - 展示各币种价格在布林带中的相对位置。 + + 每个币种按 %B 值(价格在布林带中的位置)分布: + - %B < 0: 跌破下轨 + - %B = 0: 在下轨 + - %B = 0.5: 在中轨 + - %B = 1: 在上轨 + - %B > 1: 突破上轨 + + 必填 data 字段:symbol, percent_b (百分比b) + 可选 data 字段: + - bandwidth: 带宽,决定圆圈大小(带宽大=波动大) + - price_change: 涨跌幅,决定边框颜色(红跌绿涨) + - volume: 成交量,决定圆圈颜色深浅 + """ + data = params.get("data") + if not data or not isinstance(data, list): + raise ValueError("缺少 data 列表") + + bands = max(2, int(params.get("bands", 5))) + + df = pd.DataFrame(data) + required_cols = {"symbol", "percent_b"} + if not required_cols.issubset(df.columns): + raise ValueError("data 需包含 symbol, percent_b") + + df = df.dropna(subset=["percent_b"]) + df["percent_b"] = df["percent_b"].astype(float) + # 过滤无效数据(%B 为 0 且带宽为 0 表示数据不足) + if "bandwidth" in df.columns: + df = df[(df["bandwidth"] > 0) | (df["percent_b"] != 0)] + + # 去重:每个币种只保留一条(取最新或第一条) + df = df.drop_duplicates(subset=["symbol"], keep="first") + + if df.empty: + raise ValueError("无有效布林带数据") + + # %B 映射到 0-1 范围(允许超出) + # 原始 %B: 0=下轨, 0.5=中轨, 1=上轨 + # 映射后 y: 0=超卖, 0.5=中轨, 1=超买 + raw_y = df["percent_b"].clip(-0.5, 1.5) # 允许一定超出 + df["y"] = ((raw_y + 0.5) / 2).clip(0.01, 0.99) # 归一化到 0-1 + df["y_raw"] = df["percent_b"] + + n = len(df) + fig_height = min(14, max(10, n * 0.028)) + + sns.set_theme(style="white") + fig, ax = plt.subplots(1, 1, figsize=(16, fig_height), dpi=150) + + # 布林带区域背景色:从超卖(蓝)到超买(红) + band_colors = ["#1565C0", "#1976D2", "#4CAF50", "#FFA726", "#E53935"] + if bands != 5: + cmap = plt.cm.RdYlBu_r # 红黄蓝反转 + band_colors = [cmap(i / max(1, bands - 1)) for i in range(bands)] + + for i in range(bands): + y0 = i / bands + ax.add_patch(plt.Rectangle((0.0, y0), 1.0, 1/bands, facecolor=band_colors[i], alpha=0.85, edgecolor="none")) + + rng = np.random.default_rng(42) + + # 带宽归一化 -> 圆圈大小(带宽大=波动大=圆圈大) + if "bandwidth" in df.columns: + bw = df["bandwidth"].fillna(df["bandwidth"].median()) + bw_log = np.log10(bw.clip(lower=0.1) + 1) + bw_norm = (bw_log - bw_log.min()) / (bw_log.max() - bw_log.min() + 1e-9) + df["size_factor"] = 0.3 + bw_norm * 1.2 # 0.3 ~ 1.5 + else: + df["size_factor"] = 1.0 + + # 成交量归一化 -> 颜色亮度 + if "volume" in df.columns: + vol = df["volume"].fillna(df["volume"].median()) + vol_log = np.log10(vol.clip(lower=1)) + vol_norm = (vol_log - vol_log.min()) / (vol_log.max() - vol_log.min() + 1e-9) + df["vol_factor"] = vol_norm + else: + df["vol_factor"] = 0.5 + + # 智能初始布局 + df = df.sort_values("y").reset_index(drop=True) + y_bins = pd.cut(df["y"], bins=25, labels=False) + df["y_bin"] = y_bins.fillna(0).astype(int) + + x_positions = [] + for bin_id in range(25): + bin_mask = df["y_bin"] == bin_id + bin_count = bin_mask.sum() + if bin_count > 0: + bin_indices = df[bin_mask].index.tolist() + for i, idx in enumerate(bin_indices): + x = (i + 0.5) / bin_count * 0.88 + 0.06 + x += rng.uniform(-0.015, 0.015) + x_positions.append((idx, x)) + + for idx, x in x_positions: + df.loc[idx, "x"] = x + df["x"] = df["x"].clip(0.03, 0.97) + + # 绘制圆圈 + base_font = 5.0 + texts = [] + vol_cmap = plt.cm.YlOrRd # 黄到红:低成交量黄,高成交量红 + + for _, row in df.iterrows(): + label = str(row["symbol"]).replace("USDT", "") + if len(label) > 6: + label = label[:6] + ".." + + size_factor = row.get("size_factor", 1.0) + font_size = base_font * (0.8 + size_factor * 0.7) + + vol_factor = row.get("vol_factor", 0.5) + rgba = vol_cmap(vol_factor) + point_color = f"#{int(rgba[0]*255):02x}{int(rgba[1]*255):02x}{int(rgba[2]*255):02x}" + + # 涨跌决定边框颜色 + chg = row.get("price_change") + if chg is not None and chg > 0.005: + edge_color = "#1a9850" + elif chg is not None and chg < -0.005: + edge_color = "#d73027" + else: + edge_color = "#ffffff" + + edge_width = 1.0 + size_factor * 1.2 + + txt = ax.text( + row["x"], row["y"], label, + ha="center", va="center", + fontsize=font_size, + color="#1a1a1a", + fontweight="bold", + zorder=4, + bbox=dict(boxstyle="circle,pad=0.4", facecolor=point_color, edgecolor=edge_color, linewidth=edge_width, alpha=0.92), + ) + texts.append(txt) + + # adjustText 微调 + try: + adjust_text( + texts, + x=df["x"].tolist(), + y=df["y"].tolist(), + ax=ax, + expand=(1.03, 1.05), + force_text=(0.2, 0.3), + force_static=(0.05, 0.08), + force_pull=(0.02, 0.02), + arrowprops=dict(arrowstyle="-", color="#666666", lw=0.3, alpha=0.4), + time_lim=1.5, + only_move={"text": "xy"}, + ) + except Exception as e: + logger.warning("adjustText failed: %s", e) + + # 样式 + for spine in ["top", "right", "bottom"]: + ax.spines[spine].set_visible(False) + ax.spines["left"].set_color("#444444") + ax.spines["left"].set_linewidth(1.2) + + ax.set_xticks([]) + # Y 轴标签:%B 值(英文避免字体问题) + ax.set_yticks([0, 0.25, 0.5, 0.75, 1.0]) + ax.set_yticklabels(["-50%\n(Oversold)", "0%\n(Lower)", "50%\n(Middle)", "100%\n(Upper)", "150%\n(Overbought)"], fontsize=9, color="#333333") + ax.set_ylabel("Bollinger %B Position", fontsize=10, color="#333333", labelpad=8) + + # 图例 + from matplotlib.lines import Line2D + legend_elements = [ + Line2D([0], [0], marker='o', color='w', markerfacecolor=band_colors[-1], markersize=10, label='Overbought (>100%)'), + Line2D([0], [0], marker='o', color='w', markerfacecolor=band_colors[len(band_colors)//2], markersize=10, label='Middle Band (50%)'), + Line2D([0], [0], marker='o', color='w', markerfacecolor=band_colors[0], markersize=10, label='Oversold (<0%)'), + Line2D([0], [0], marker='o', color='w', markerfacecolor='#ff6b6b', markersize=11, label='High Volume'), + Line2D([0], [0], marker='o', color='w', markerfacecolor='#ffffcc', markersize=8, label='Low Volume'), + Line2D([0], [0], marker='o', color='w', markerfacecolor='#ffcc80', markeredgecolor='#1a9850', markersize=10, markeredgewidth=2, label='Up'), + Line2D([0], [0], marker='o', color='w', markerfacecolor='#ffcc80', markeredgecolor='#d73027', markersize=10, markeredgewidth=2, label='Down'), + ] + ax.legend(handles=legend_elements, loc='lower right', fontsize=9, framealpha=0.9, edgecolor='#cccccc') + + ax.set_xlim(-0.01, 1.01) + ax.set_ylim(-0.02, 1.02) + + fig.suptitle(params.get("title", "Bollinger Band Distribution"), fontsize=12, color="#1e293b", fontweight="bold", y=0.98) + fig.tight_layout(rect=[0, 0.02, 0.92, 0.96]) + + if output == "json": + return ( + { + "title": params.get("title", "Bollinger Band Distribution"), + "bands": bands, + "points": [{"symbol": row["symbol"], "percent_b": float(row["y_raw"]), "x": float(row["x"]), + "size_factor": float(row.get("size_factor", 1)), "vol_factor": float(row.get("vol_factor", 0.5))} + for _, row in df.iterrows()], + }, + "application/json", + ) + + return _fig_to_png(fig), "image/png" + + def register_defaults() -> TemplateRegistry: """注册内置模板,并返回注册表实例。""" @@ -1295,4 +1501,30 @@ def register_defaults() -> TemplateRegistry: ), render_vpvr_ridge, ) + registry.register( + TemplateMeta( + template_id="bb-zone-strip", + name="布林带分布图", + description="全市场布林带 %B 位置分布,展示各币种在布林带中的相对位置(超买/超卖)", + outputs=["png", "json"], + params=[ + "data(list[{symbol, percent_b, bandwidth?, price_change?, volume?}])", + "bands?(int, default 5)", + "title?", + ], + sample={ + "template_id": "bb-zone-strip", + "output": "png", + "params": { + "bands": 5, + "data": [ + {"symbol": "BTCUSDT", "percent_b": 0.85, "bandwidth": 15.5, "price_change": 0.02}, + {"symbol": "ETHUSDT", "percent_b": 0.45, "bandwidth": 20.3, "price_change": -0.01}, + {"symbol": "SOLUSDT", "percent_b": 0.12, "bandwidth": 25.8, "price_change": -0.03}, + ], + }, + }, + ), + render_bb_zone_strip, + ) return registry