diff --git a/libs/common/symbols.py b/libs/common/symbols.py index 5a1361b7..3cdecaae 100644 --- a/libs/common/symbols.py +++ b/libs/common/symbols.py @@ -63,3 +63,15 @@ def get_configured_symbols_set() -> Optional[Set[str]]: """ result = get_configured_symbols() return set(result) if result else None + + +def reload_symbols(): + """ + 强制重新加载币种配置(用于热更新) + + 注意:此函数本身不缓存,每次调用 get_configured_symbols() 都会重新读取环境变量。 + 此函数主要用于通知依赖模块(如 data_provider)刷新其缓存。 + """ + # symbols.py 本身每次都从 os.environ 读取,无需清理 + # 但需要通知其他模块刷新缓存 + pass diff --git a/services-preview/markets-service/docs/order-book-audit-report.md b/services-preview/markets-service/docs/order-book-audit-report.md new file mode 100644 index 00000000..8f04405f --- /dev/null +++ b/services-preview/markets-service/docs/order-book-audit-report.md @@ -0,0 +1,348 @@ +# 订单簿采集系统审计报告 + +> 审计日期: 2026-01-09 +> 审计范围: `raw.crypto_order_book`, `raw.crypto_order_book_tick`, `order_book.py` +> 审计状态: **已修复** + +--- + +## 1. 摘要 + +| 项目 | 内容 | +|:---|:---| +| 审计输入 | DDL + 采集器代码 | +| 总体风险 | 低 (修复后) | +| 风险计分 | Critical 0 / High 0 / Medium 0 / Low 1 | +| 关键修复 | lastUpdateId 采集、乱序检测、错误可观测性 | + +--- + +## 2. 表结构设计 + +### 2.1 raw.crypto_order_book_tick (L1 Tick 层) + +``` ++-----+-------------------+---------------+----------+---------+---------------------------+ +| No. | Column | Type | Nullable | Default | Description | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| 1 | exchange | TEXT | NOT NULL | | 交易所 | +| 2 | symbol | TEXT | NOT NULL | | 交易对 | +| 3 | timestamp | TIMESTAMPTZ | NOT NULL | | 快照时间 | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| 4 | mid_price | NUMERIC(38,18)| | | 中间价 | +| 5 | spread_bps | NUMERIC(10,4) | | | 价差基点 | +| 6 | bid1_price | NUMERIC(38,18)| | | 最优买价 | +| 7 | bid1_size | NUMERIC(38,18)| | | 最优买量 | +| 8 | ask1_price | NUMERIC(38,18)| | | 最优卖价 | +| 9 | ask1_size | NUMERIC(38,18)| | | 最优卖量 | +| 10 | bid_depth_1pct | NUMERIC(38,8) | | | 买侧1%深度 | +| 11 | ask_depth_1pct | NUMERIC(38,8) | | | 卖侧1%深度 | +| 12 | imbalance | NUMERIC(10,6) | | | 买卖失衡 [-1,1] | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| 13 | source | TEXT | NOT NULL | binance_ws | 数据源 | +| 14 | ingest_batch_id | BIGINT | | | 采集批次ID | +| 15 | source_event_time | TIMESTAMPTZ | | | 源事件时间 | +| 16 | ingested_at | TIMESTAMPTZ | NOT NULL | now() | 入库时间 | +| 17 | updated_at | TIMESTAMPTZ | NOT NULL | now() | 更新时间 | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| PK: (exchange, symbol, timestamp) | +| Indexes: symbol_time, time, batch_id, spread(partial), imbalance(partial) | +| Chunk: 6h | Compress: 6h | Segmentby: exchange,symbol | ++----------------------------------------------------------------------------------------+ +``` + +### 2.2 raw.crypto_order_book (L2 Full 层) + +``` ++-----+-------------------+---------------+----------+---------+---------------------------+ +| No. | Column | Type | Nullable | Default | Description | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| 1 | exchange | TEXT | NOT NULL | | 交易所 | +| 2 | symbol | TEXT | NOT NULL | | 交易对 | +| 3 | timestamp | TIMESTAMPTZ | NOT NULL | | 事件时间 (E) | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| 4 | last_update_id | BIGINT | | | 序列号 (lastUpdateId) | +| 5 | transaction_time | TIMESTAMPTZ | | | 交易时间 (T) | +| 6 | depth | INT | NOT NULL | | 档位数 | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| 7 | mid_price | NUMERIC(38,18)| | | 中间价 | +| 8 | spread | NUMERIC(38,18)| | | 价差 | +| 9 | spread_bps | NUMERIC(10,4) | | | 价差基点 | +| 10 | bid1_price | NUMERIC(38,18)| | | 最优买价 | +| 11 | bid1_size | NUMERIC(38,18)| | | 最优买量 | +| 12 | ask1_price | NUMERIC(38,18)| | | 最优卖价 | +| 13 | ask1_size | NUMERIC(38,18)| | | 最优卖量 | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| 14 | bid_depth_1pct | NUMERIC(38,8) | | | 买侧1%深度 | +| 15 | ask_depth_1pct | NUMERIC(38,8) | | | 卖侧1%深度 | +| 16 | bid_depth_5pct | NUMERIC(38,8) | | | 买侧5%深度 | +| 17 | ask_depth_5pct | NUMERIC(38,8) | | | 卖侧5%深度 | +| 18 | bid_notional_1pct | NUMERIC(38,8) | | | 买侧1%名义价值(USDT) | +| 19 | ask_notional_1pct | NUMERIC(38,8) | | | 卖侧1%名义价值 | +| 20 | bid_notional_5pct | NUMERIC(38,8) | | | 买侧5%名义价值 | +| 21 | ask_notional_5pct | NUMERIC(38,8) | | | 卖侧5%名义价值 | +| 22 | imbalance | NUMERIC(10,6) | | | 买卖失衡 | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| 23 | bids | JSONB | NOT NULL | | 买盘 [["price","qty"],...] | +| 24 | asks | JSONB | NOT NULL | | 卖盘 [["price","qty"],...] | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| 25 | source | TEXT | NOT NULL | binance_ws | 数据源 | +| 26 | ingest_batch_id | BIGINT | | | 采集批次ID | +| 27 | source_event_time | TIMESTAMPTZ | | | 原始事件时间 | +| 28 | ingested_at | TIMESTAMPTZ | NOT NULL | now() | 入库时间 | +| 29 | updated_at | TIMESTAMPTZ | NOT NULL | now() | 更新时间 | ++-----+-------------------+---------------+----------+---------+---------------------------+ +| PK: (exchange, symbol, timestamp) | +| Indexes: symbol_time, time, batch_id, spread(partial) | +| Chunk: 1d | Compress: 1d | Segmentby: exchange,symbol | ++----------------------------------------------------------------------------------------+ +``` + +### 2.3 原始数据格式对照 + +| Binance 原始字段 | 数据库字段 | 说明 | +|:---|:---|:---| +| `lastUpdateId` | `last_update_id` | 序列号,用于乱序检测 | +| `E` (event_time) | `timestamp` | 事件时间,主键 | +| `T` (transaction_time) | `transaction_time` | 交易时间 (待采集) | +| `bids` | `bids` (JSONB) | 原始格式 `[["price","qty"],...]` | +| `asks` | `asks` (JSONB) | 原始格式 `[["price","qty"],...]` | + +--- + +## 3. 规范对齐检查 + +### 3.1 与 crypto_kline_1m 对比 + +| 设计项 | crypto_kline_1m | crypto_order_book | 状态 | +|:---|:---|:---|:---| +| 字段顺序 | exchange,symbol,open_time | exchange,symbol,timestamp | OK | +| 主键 | (exchange,symbol,open_time) | (exchange,symbol,timestamp) | OK | +| 血缘字段 | 5字段 | 5字段 (相同) | OK | +| 索引 | symbol_time,time,batch_id | symbol_time,time,batch_id | OK | +| 压缩分段 | exchange,symbol | exchange,symbol | OK | +| 保留策略 | 注释 (不自动删除) | 注释 (不自动删除) | OK | + +### 3.2 数据完整性 + +| 检查项 | 状态 | 说明 | +|:---|:---|:---| +| 原始档位数 | OK | 1000档/侧,可配置 | +| 原始格式 | OK | `[["price","qty"],...]` 字符串保留精度 | +| lastUpdateId | OK | 从 cryptofeed sequence_number 提取 | +| transaction_time | 待完善 | cryptofeed 不提供,字段预留 | +| 多交易所支持 | OK | exchange 字段区分 | + +--- + +## 4. 安全审计发现与修复 + +### 4.1 已修复 (Medium → Resolved) + +#### 4.1.1 缺失去重与序号校验 + +- **风险**: 重放/乱序数据可能污染订单簿 +- **修复**: + - 提取 `book.sequence_number` 作为 `lastUpdateId` + - 添加单调递增校验,拒绝乱序数据 + - 添加 `order_book_out_of_order` 指标 + +```python +# 乱序检测 +if last_update_id < prev_id: + self._stats["out_of_order"] += 1 + metrics.inc("order_book_out_of_order") + logger.warning("乱序跳过: %s seq %d < %d", sym, last_update_id, prev_id) + return +``` + +#### 4.1.2 错误静默吞掉 + +- **风险**: 写库失败时数据缺口无感知 +- **修复**: + - 添加 `exc_info=True` 输出完整堆栈 + - 添加丢失数据计数 + - 添加 `order_book_write_errors` 指标 + +```python +except Exception as e: + self._stats["errors"] += 1 + metrics.inc("order_book_write_errors") + logger.error("写入失败 (%d 条丢失): %s", len(rows), e, exc_info=True) +``` + +### 4.2 已修复 (Low → Resolved) + +#### 4.2.1 采集列顺序不一致 + +- **风险**: 历史数据可能存在列错位 +- **修复**: 统一 tick/full 表列顺序为 `exchange,symbol,timestamp,...` + +### 4.3 待处理 (Low) + +| 项目 | 说明 | 建议 | +|:---|:---|:---| +| transaction_time | cryptofeed 不提供 | 改用原生 WebSocket 或接受缺失 | +| 历史数据校验 | 修复前数据可能错位 | 手动抽样比对 | + +--- + +## 5. 可观测性增强 + +### 5.1 新增指标 + +| 指标名 | 类型 | 说明 | +|:---|:---|:---| +| `order_book_tick_written` | Counter | tick 表写入行数 | +| `order_book_full_written` | Counter | full 表写入行数 | +| `order_book_write_errors` | Counter | 写入错误次数 | +| `order_book_out_of_order` | Counter | 乱序跳过次数 | + +### 5.2 统计日志 + +每 60 秒输出一次运行统计: + +``` +INFO 统计: received=12000, tick=4000, full=800, errors=0, out_of_order=0 +``` + +退出时输出最终统计: + +``` +INFO 采集结束统计: received=120000, tick=40000, full=8000, errors=0, out_of_order=0 +``` + +--- + +## 6. 存储估算 + +### 6.1 单行存储 + +| 表 | 单行大小 (未压缩) | 单行大小 (压缩后) | +|:---|:---|:---| +| crypto_order_book_tick | ~200 bytes | ~50 bytes | +| crypto_order_book | ~50 KB | ~5-10 KB | + +### 6.2 日存储量 (4币种) + +| 表 | 采样间隔 | 每日快照数 | 每日存储 (压缩后) | +|:---|:---|:---|:---| +| tick | 1s | 345,600 | ~17 MB | +| full | 5s | 69,120 | ~500 MB | + +### 6.3 月存储量 + +``` +4币种 × 30天 ≈ 15 GB (压缩后) +``` + +--- + +## 7. 验证清单 + +### 7.1 DDL 验证 + +```sql +-- 检查表结构 +\d raw.crypto_order_book_tick +\d raw.crypto_order_book + +-- 检查索引 +SELECT indexname FROM pg_indexes +WHERE tablename IN ('crypto_order_book_tick', 'crypto_order_book'); + +-- 检查压缩策略 +SELECT * FROM timescaledb_information.jobs +WHERE proc_name = 'policy_compression'; +``` + +### 7.2 数据验证 + +```sql +-- 检查 lastUpdateId 是否写入 +SELECT symbol, timestamp, last_update_id +FROM raw.crypto_order_book +ORDER BY timestamp DESC LIMIT 5; + +-- 检查档位数 +SELECT symbol, depth, + jsonb_array_length(bids) as bid_levels, + jsonb_array_length(asks) as ask_levels +FROM raw.crypto_order_book +ORDER BY timestamp DESC LIMIT 5; + +-- 验证原始格式 +SELECT bids->0->0 as price, bids->0->1 as qty +FROM raw.crypto_order_book LIMIT 1; +-- 预期: "91114.00", "16.841" (字符串) +``` + +### 7.3 代码验证 + +```bash +# 语法检查 +cd services-preview/markets-service +.venv/bin/python -m py_compile src/crypto/collectors/order_book.py + +# 运行测试 +ORDER_BOOK_SYMBOLS=BTC .venv/bin/python -m src.crypto.collectors.order_book +``` + +--- + +## 8. 配置参考 + +```bash +# config/.env +ORDER_BOOK_TICK_INTERVAL=1 # tick 采样间隔 (秒) +ORDER_BOOK_FULL_INTERVAL=5 # full 采样间隔 (秒) +ORDER_BOOK_DEPTH=1000 # 每侧档位数 +ORDER_BOOK_RETENTION_DAYS=30 # 保留天数 (仅供参考,默认不删除) +ORDER_BOOK_SYMBOLS= # 可选,逗号分隔 +``` + +--- + +## 9. 变更历史 + +| 日期 | 变更 | 审计员 | +|:---|:---|:---| +| 2026-01-09 | 初始设计: 双层采样架构 | - | +| 2026-01-09 | 修复: 字段顺序、血缘字段、索引 | - | +| 2026-01-09 | 修复: 原始格式存储 `[["price","qty"],...]` | - | +| 2026-01-09 | 修复: lastUpdateId 采集、乱序检测 | - | +| 2026-01-09 | 增强: 错误日志、统计指标 | - | + +--- + +## 10. 附录 + +### A. 原始 Binance API 响应 + +```json +{ + "lastUpdateId": 9632865900189, + "E": 1767941019719, + "T": 1767941019709, + "bids": [ + ["91114.00", "16.841"], + ["91113.90", "0.004"], + ... + ], + "asks": [ + ["91114.10", "3.319"], + ["91114.20", "0.008"], + ... + ] +} +``` + +### B. cryptofeed 字段映射 + +| cryptofeed 属性 | Binance 字段 | 数据库字段 | +|:---|:---|:---| +| `book.timestamp` | `E` | `timestamp` | +| `book.sequence_number` | `lastUpdateId` | `last_update_id` | +| `book.book.bids` | `bids` | `bids` | +| `book.book.asks` | `asks` | `asks` | +| - | `T` | `transaction_time` (未采集) | diff --git a/services-preview/markets-service/docs/order_book_audit_2026-01-09.md b/services-preview/markets-service/docs/order_book_audit_2026-01-09.md new file mode 100644 index 00000000..d678cf7f --- /dev/null +++ b/services-preview/markets-service/docs/order_book_audit_2026-01-09.md @@ -0,0 +1,57 @@ +# 订单簿采集系统审计报告(保存版) +> 审计日期:2026-01-09 +> 范围:`raw.crypto_order_book`、`raw.crypto_order_book_tick`、`services-preview/markets-service/src/crypto/collectors/order_book.py` +> 状态:修复完成(剩余 1 个待完善项) + +## 1. 摘要 +- 总体风险:低(核心幂等与观测已补齐,仍缺交易时间 T 采集) +- 计分:Critical 0 / High 0 / Medium 0 / Low 1 +- 关键修复:`lastUpdateId` 采集与乱序检测、列顺序一致、写库错误可观测 +- 待处理:`transaction_time (T)` 未采集,消费侧需降级或改用原生 WS 获取 + +## 2. 表结构要点 +- `raw.crypto_order_book_tick`:PK `(exchange,symbol,timestamp)`;chunk 6h,compress 6h;索引 `symbol_time/time/batch/spread/imbalance`。 +- `raw.crypto_order_book`:PK `(exchange,symbol,timestamp)`;含 `last_update_id`、`transaction_time` 预留;chunk 1d,compress 1d;索引 `symbol_time/time/batch/spread`。 +- 两表血缘字段与 `crypto_kline_1m` 对齐:`source/ingest_batch_id/source_event_time/ingested_at/updated_at`。 + +## 3. 代码要点(采集器) +- `_on_book`:提取 `book.sequence_number` 写入 `last_update_id`;若序号倒退则跳过并计数 `order_book_out_of_order`。 +- Tick/Full 双缓冲;列顺序统一为 `exchange, symbol, timestamp, ...`。 +- 错误处理:写库异常计数 `order_book_write_errors` 并输出堆栈。 + +## 4. 观测与验证 +- 新增指标:`order_book_tick_written`、`order_book_full_written`、`order_book_write_errors`、`order_book_out_of_order`。 +- 推荐验证: + - 抽样 SQL:检查 `last_update_id` 非空、档位数、原始格式 `"[\"price\",\"qty\"]"`。 + - 乱序模拟:重复/倒序 `lastUpdateId` 事件应被拒。 + - 语法检查:`cd services-preview/markets-service && .venv/bin/python -m py_compile src/crypto/collectors/order_book.py` + - 运行测试(需 DB/代理):`ORDER_BOOK_SYMBOLS=BTC .venv/bin/python -m src.crypto.collectors.order_book` + +## 5. 残留风险与行动项 +| 优先级 | 项目 | 说明 | 建议 | +|:--|:--|:--|:--| +| P1 | 采集 `transaction_time (T)` | cryptofeed 不提供 | 若需严格顺序,用 Binance 原生 WS 获取 T;否则在消费端明确忽略 | +| P2 | 历史数据抽样 | 修复前可能有列错位或空序号 | 抽样比对修复前窗口;必要时回填/截断 | +| P2 | 指标告警 | 需接入 Prometheus/告警 | 对 `order_book_out_of_order` 与写入错误设阈值告警 | + +## 6. 存储估算(4 币种,压缩后) +- Tick(1s):~17 MB/天;Full(5s):~0.5 GB/天;约 15 GB/月。 + +## 7. 参考 SQL 片段 +```sql +-- 表结构与索引 +\\d raw.crypto_order_book_tick; +\\d raw.crypto_order_book; +SELECT indexname FROM pg_indexes WHERE tablename IN ('crypto_order_book_tick','crypto_order_book'); +-- 压缩策略 +SELECT * FROM timescaledb_information.jobs WHERE proc_name = 'policy_compression'; +-- 数据抽样 +SELECT symbol, timestamp, last_update_id, jsonb_array_length(bids) AS bid_levels +FROM raw.crypto_order_book ORDER BY timestamp DESC LIMIT 5; +``` + +## 8. 变更快照 +- DDL:`services-preview/markets-service/scripts/ddl/03_raw_crypto.sql` +- 代码:`services-preview/markets-service/src/crypto/collectors/order_book.py` + +(完) diff --git a/services-preview/vis-service/scripts/start.sh b/services-preview/vis-service/scripts/start.sh index ad1ae0f9..c3a699d4 100755 --- a/services-preview/vis-service/scripts/start.sh +++ b/services-preview/vis-service/scripts/start.sh @@ -23,9 +23,13 @@ safe_load_env() { local perm perm=$(stat -c %a "$file" 2>/dev/null) if [[ "$perm" != "600" && "$perm" != "400" ]]; then - echo "❌ 错误: $file 权限为 $perm,必须设为 600" - echo " 执行: chmod 600 $file" - exit 1 + if [[ "${CODESPACES:-}" == "true" ]]; then + echo "⚠️ Codespace 环境,跳过权限检查 ($file: $perm)" + else + echo "❌ 错误: $file 权限为 $perm,必须设为 600" + echo " 执行: chmod 600 $file" + exit 1 + fi fi fi diff --git a/services-preview/vis-service/src/templates/registry.py b/services-preview/vis-service/src/templates/registry.py index 0152c76b..7447d9b5 100644 --- a/services-preview/vis-service/src/templates/registry.py +++ b/services-preview/vis-service/src/templates/registry.py @@ -1086,18 +1086,19 @@ def price_to_fig_x(price): def render_bb_zone_strip(params: Dict, output: str) -> Tuple[object, str]: """ - 全市场布林带分布图 - 展示各币种价格在布林带中的相对位置。 + 全市场布林带分布图 - 九宫格矩阵。 - 每个币种按 %B 值(价格在布林带中的位置)分布: - - %B < 0: 跌破下轨 - - %B = 0: 在下轨 - - %B = 0.5: 在中轨 - - %B = 1: 在上轨 - - %B > 1: 突破上轨 + Y轴:%B 值(价格在布林带中的位置) + X轴:带宽(波动率)分3区 - 收窄/正常/扩张 - 必填 data 字段:symbol, percent_b (百分比b) + 九宫格解读: + - 左上:收窄+超买 → 向上突破前兆 + - 右上:扩张+超买 → 疯狂追涨 + - 左下:收窄+超卖 → 向下突破前兆 + - 右下:扩张+超卖 → 恐慌抛售 + + 必填 data 字段:symbol, percent_b, bandwidth 可选 data 字段: - - bandwidth: 带宽,决定圆圈大小(带宽大=波动大) - price_change: 涨跌幅,决定边框颜色(红跌绿涨) - volume: 成交量,决定圆圈颜色深浅 """ @@ -1105,60 +1106,49 @@ def render_bb_zone_strip(params: Dict, output: str) -> Tuple[object, str]: if not data or not isinstance(data, list): raise ValueError("缺少 data 列表") - bands = max(2, int(params.get("bands", 5))) + y_bands = max(2, int(params.get("bands", 5))) # Y轴分区数 + x_bands = 3 # X轴固定3区:收窄/正常/扩张 df = pd.DataFrame(data) - required_cols = {"symbol", "percent_b"} + required_cols = {"symbol", "percent_b", "bandwidth"} if not required_cols.issubset(df.columns): - raise ValueError("data 需包含 symbol, percent_b") + raise ValueError("data 需包含 symbol, percent_b, bandwidth") - df = df.dropna(subset=["percent_b"]) + df = df.dropna(subset=["percent_b", "bandwidth"]) 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["bandwidth"] = df["bandwidth"].astype(float) + df = df[df["bandwidth"] > 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 + # Y轴:%B 映射到 0-1 + raw_y = df["percent_b"].clip(-0.5, 1.5) + df["y"] = ((raw_y + 0.5) / 2).clip(0.02, 0.98) 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)] + # X轴:带宽按分位数分3区 + bw = df["bandwidth"] + q33 = bw.quantile(0.33) + q66 = bw.quantile(0.66) - 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")) + def bw_to_x_zone(b): + if b <= q33: + return 0 # 收窄 + elif b <= q66: + return 1 # 正常 + else: + return 2 # 扩张 - rng = np.random.default_rng(42) + df["x_zone"] = df["bandwidth"].apply(bw_to_x_zone) - # 带宽归一化 -> 圆圈大小(带宽大=波动大=圆圈大) - 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 + # 带宽归一化用于圆圈大小 + 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.4 + bw_norm * 1.0 - # 成交量归一化 -> 颜色亮度 + # 成交量归一化 -> 颜色 if "volume" in df.columns: vol = df["volume"].fillna(df["volume"].median()) vol_log = np.log10(vol.clip(lower=1)) @@ -1167,44 +1157,122 @@ def render_bb_zone_strip(params: Dict, output: str) -> Tuple[object, str]: 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) + n = len(df) + fig_height = min(14, max(10, n * 0.025)) - 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)) + sns.set_theme(style="white") + fig, ax = plt.subplots(1, 1, figsize=(16, fig_height), dpi=150) - for idx, x in x_positions: - df.loc[idx, "x"] = x - df["x"] = df["x"].clip(0.03, 0.97) + # Y轴背景色带 + y_band_colors = ["#1565C0", "#1976D2", "#4CAF50", "#FFA726", "#E53935"] + if y_bands != 5: + cmap = plt.cm.RdYlBu_r + y_band_colors = [cmap(i / max(1, y_bands - 1)) for i in range(y_bands)] + + for i in range(y_bands): + y0 = i / y_bands + for j in range(x_bands): + x0 = j / x_bands + ax.add_patch(plt.Rectangle( + (x0, y0), 1/x_bands, 1/y_bands, + facecolor=y_band_colors[i], alpha=0.75, edgecolor="white", linewidth=0.5 + )) + + # X轴分区线和标签 + for i in range(1, x_bands): + ax.axvline(x=i/x_bands, color="white", linewidth=2, alpha=0.9) + + # 贪心算法防重叠布局 v3 + def get_radius(size_factor): + return 0.015 + size_factor * 0.008 # 缩小气泡 + + def check_overlap(x1, y1, r1, placed_list): + """检查与所有已放置气泡的重叠数""" + count = 0 + for px, py, pr in placed_list: + dx = (x1 - px) * 1.5 + dy = y1 - py + dist = (dx**2 + dy**2) ** 0.5 + if dist < (r1 + pr) * 0.95: + count += 1 + return count + + df["x"] = 0.5 + df["y_final"] = df["y"] + + for zone in range(x_bands): + zone_mask = df["x_zone"] == zone + zone_indices = df[zone_mask].index.tolist() + if not zone_indices: + continue - # 绘制圆圈 - base_font = 5.0 + zone_x_start = zone / x_bands + 0.02 + zone_x_end = (zone + 1) / x_bands - 0.02 + + # 按 size_factor 降序(大气泡优先放) + zone_df = df.loc[zone_indices].sort_values("size_factor", ascending=False) + zone_placed = [] + + # X 方向搜索点(更密集) + x_grid = np.linspace(zone_x_start + 0.015, zone_x_end - 0.015, 30) + # Y 方向偏移(更大范围) + y_offsets = [0] + [d * s for d in range(1, 25) for s in [-0.01, 0.01]] + + for idx in zone_df.index: + row = df.loc[idx] + target_y = row["y"] + radius = get_radius(row["size_factor"]) + + best_pos = None + best_score = float("inf") + + for try_x in x_grid: + for y_off in y_offsets: + try_y = target_y + y_off + if try_y < 0.02 or try_y > 0.98: + continue + + overlap = check_overlap(try_x, try_y, radius, zone_placed) + score = overlap * 100 + abs(y_off) * 10 + abs(try_x - (zone_x_start + zone_x_end)/2) * 2 + + if score < best_score: + best_score = score + best_pos = (try_x, try_y) + + if overlap == 0: + break + if best_score == 0: + break + + if best_pos: + df.loc[idx, "x"] = best_pos[0] + df.loc[idx, "y_final"] = best_pos[1] + zone_placed.append((best_pos[0], best_pos[1], radius)) + else: + df.loc[idx, "x"] = (zone_x_start + zone_x_end) / 2 + df.loc[idx, "y_final"] = target_y + zone_placed.append(((zone_x_start + zone_x_end) / 2, target_y, radius)) + + df["y"] = df["y_final"] + df["x"] = df["x"].clip(0.02, 0.98) + + # 绘制气泡 + base_font = 4.0 # 缩小字体 texts = [] - vol_cmap = plt.cm.YlOrRd # 黄到红:低成交量黄,高成交量红 + vol_cmap = plt.cm.YlOrRd for _, row in df.iterrows(): label = str(row["symbol"]).replace("USDT", "") - if len(label) > 6: - label = label[:6] + ".." + if len(label) > 5: + label = label[:5] + ".." size_factor = row.get("size_factor", 1.0) - font_size = base_font * (0.8 + size_factor * 0.7) + font_size = base_font * (0.75 + size_factor * 0.4) # 缩小 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" @@ -1213,7 +1281,7 @@ def render_bb_zone_strip(params: Dict, output: str) -> Tuple[object, str]: else: edge_color = "#ffffff" - edge_width = 1.0 + size_factor * 1.2 + edge_width = 0.8 + size_factor * 0.6 txt = ax.text( row["x"], row["y"], label, @@ -1222,66 +1290,60 @@ def render_bb_zone_strip(params: Dict, output: str) -> Tuple[object, str]: 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), + bbox=dict(boxstyle="circle,pad=0.25", facecolor=point_color, edgecolor=edge_color, linewidth=edge_width, alpha=0.92), ) texts.append(txt) - # adjustText 微调 + # 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"}, + texts, x=df["x"].tolist(), y=df["y"].tolist(), ax=ax, + expand=(1.02, 1.03), force_text=(0.15, 0.2), force_static=(0.03, 0.05), + force_pull=(0.01, 0.01), time_lim=1.2, only_move={"text": "xy"}, + arrowprops=dict(arrowstyle="-", color="#666666", lw=0.3, alpha=0.3), ) 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) + for spine in ax.spines.values(): + spine.set_visible(False) - 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) + # Y轴标签 + ax.set_yticks([0.1, 0.3, 0.5, 0.7, 0.9]) + ax.set_yticklabels(["Oversold\n(<0%)", "Lower\n(0-25%)", "Middle\n(50%)", "Upper\n(75-100%)", "Overbought\n(>100%)"], fontsize=9, color="#333") + ax.set_ylabel("Bollinger %B", fontsize=11, color="#333", labelpad=10) + + # X轴标签 + ax.set_xticks([1/6, 3/6, 5/6]) + ax.set_xticklabels(["Squeeze\n(Narrowing)", "Normal", "Expansion\n(Volatile)"], fontsize=10, color="#333") + ax.set_xlabel("Bandwidth (Volatility)", fontsize=11, color="#333", labelpad=10) # 图例 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='#ff6b6b', markersize=10, 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'), + Line2D([0], [0], marker='o', color='w', markerfacecolor='#ffcc80', markeredgecolor='#1a9850', markersize=9, markeredgewidth=2, label='Rising'), + Line2D([0], [0], marker='o', color='w', markerfacecolor='#ffcc80', markeredgecolor='#d73027', markersize=9, markeredgewidth=2, label='Falling'), ] - ax.legend(handles=legend_elements, loc='lower right', fontsize=9, framealpha=0.9, edgecolor='#cccccc') + ax.legend(handles=legend_elements, loc='upper left', fontsize=9, framealpha=0.9, edgecolor='#ccc') - ax.set_xlim(-0.01, 1.01) - ax.set_ylim(-0.02, 1.02) + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) - 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]) + fig.suptitle(params.get("title", "Bollinger Band Matrix"), fontsize=13, color="#1e293b", fontweight="bold", y=0.98) + fig.tight_layout(rect=[0, 0.03, 1, 0.95]) 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))} + "title": params.get("title", "Bollinger Band Matrix"), + "y_bands": y_bands, + "x_zones": ["squeeze", "normal", "expansion"], + "bandwidth_thresholds": {"q33": float(q33), "q66": float(q66)}, + "points": [{"symbol": row["symbol"], "percent_b": float(row["y_raw"]), "bandwidth": float(row["bandwidth"]), + "x_zone": int(row["x_zone"]), "x": float(row["x"]), "y": float(row["y"])} for _, row in df.iterrows()], }, "application/json", diff --git a/services/data-service/scripts/start.sh b/services/data-service/scripts/start.sh index 5fa3b763..0b3b34fd 100755 --- a/services/data-service/scripts/start.sh +++ b/services/data-service/scripts/start.sh @@ -24,9 +24,13 @@ safe_load_env() { if [[ "$file" == *"config/.env" ]] && [[ ! "$file" == *".example" ]]; then local perm=$(stat -c %a "$file" 2>/dev/null) if [[ "$perm" != "600" && "$perm" != "400" ]]; then - echo "❌ 错误: $file 权限为 $perm,必须设为 600" - echo " 执行: chmod 600 $file" - exit 1 + if [[ "${CODESPACES:-}" == "true" ]]; then + echo "⚠️ Codespace 环境,跳过权限检查 ($file: $perm)" + else + echo "❌ 错误: $file 权限为 $perm,必须设为 600" + echo " 执行: chmod 600 $file" + exit 1 + fi fi fi diff --git a/services/telegram-service/scripts/start.sh b/services/telegram-service/scripts/start.sh index 3b5d5175..58772f1e 100755 --- a/services/telegram-service/scripts/start.sh +++ b/services/telegram-service/scripts/start.sh @@ -24,9 +24,13 @@ safe_load_env() { if [[ "$file" == *"config/.env" ]] && [[ ! "$file" == *".example" ]]; then local perm=$(stat -c %a "$file" 2>/dev/null) if [[ "$perm" != "600" && "$perm" != "400" ]]; then - echo "❌ 错误: $file 权限为 $perm,必须设为 600" - echo " 执行: chmod 600 $file" - exit 1 + if [[ "${CODESPACES:-}" == "true" ]]; then + echo "⚠️ Codespace 环境,跳过权限检查 ($file: $perm)" + else + echo "❌ 错误: $file 权限为 $perm,必须设为 600" + echo " 执行: chmod 600 $file" + exit 1 + fi fi fi diff --git a/services/telegram-service/src/bot/app.py b/services/telegram-service/src/bot/app.py index 85f76d9a..b74c1e4f 100644 --- a/services/telegram-service/src/bot/app.py +++ b/services/telegram-service/src/bot/app.py @@ -68,7 +68,7 @@ # 延后导入依赖于 sys.path 的模块 from libs.common.i18n import build_i18n_from_env -# 当以脚本方式运行(__main__)时,为避免 utils.signal_formatter 反向导入失败,显式注册模块别名 +# 当以脚本方式运行时,显式注册模块别名 if __name__ == "__main__": sys.modules.setdefault("main", sys.modules[__name__]) @@ -255,18 +255,6 @@ def _period_text_lang(lang: str, period: str) -> str: sys.path = [p for p in sys.path if not (p.endswith('/src') and not Path(p).exists())] -def _load_signal_formatter(): - """避免与 ai.utils 冲突,按绝对路径加载信号格式化器""" - module_name = "telegram_signal_formatter" - if module_name in sys.modules: - return sys.modules[module_name].SignalFormatter - module_path = SRC_ROOT / "bot" / "signal_formatter.py" - spec = importlib.util.spec_from_file_location(module_name, module_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - sys.modules[module_name] = module - return module.SignalFormatter - # 数据库指标服务(可选) BINANCE_DB_METRIC_SERVICE = None @@ -370,9 +358,14 @@ def format_beijing_time(dt_str, format_str="%Y-%m-%d %H:%M:%S"): BINANCE_SPOT_URL = 'https://api.binance.com' BINANCE_API_DISABLED = _require_env('BINANCE_API_DISABLED', default='1') == '1' -# 屏蔽币种(从环境变量读取,逗号分隔) -_blocked_str = _require_env('BLOCKED_SYMBOLS', default='BNXUSDT,ALPACAUSDT') -BLOCKED_SYMBOLS = set(s.strip().upper() for s in _blocked_str.split(',') if s.strip()) +# 屏蔽币种(动态获取,支持热更新) +def get_blocked_symbols() -> set: + """动态获取屏蔽币种(支持热更新)""" + blocked_str = os.environ.get('BLOCKED_SYMBOLS', 'BNXUSDT,ALPACAUSDT') + return set(s.strip().upper() for s in blocked_str.split(',') if s.strip()) + +# 保留全局变量用于向后兼容,但建议使用 get_blocked_symbols() +BLOCKED_SYMBOLS = get_blocked_symbols() # 🔁 策略扫描脚本路径(用于定时刷新 CSV 榜单) @@ -973,8 +966,6 @@ class UserRequestHandler: """专门处理用户请求的轻量级处理器 - 只读取缓存,不进行网络请求""" def __init__(self, card_registry: Optional[RankingRegistry] = None): - # 需要屏蔽的币种列表(从全局配置读取) - self.blocked_symbols = BLOCKED_SYMBOLS # 用户状态管理 self.user_states = { 'position_sort': 'desc', @@ -1386,7 +1377,7 @@ def get_futures_volume_ranking(self, limit=10, period='24h', sort_order='desc', processed = [] for row in rows: symbol = row.get('symbol', '') - if not symbol or symbol in self.blocked_symbols: + if not symbol or symbol in get_blocked_symbols(): continue volume = float(row.get('quote_volume') or 0) price = float(row.get('last_close') or 0) @@ -1448,7 +1439,7 @@ def get_spot_volume_ranking(self, limit=10, period='24h', sort_order='desc', sor processed = [] for row in rows: symbol = row.get('symbol', '') - if not symbol or symbol in self.blocked_symbols: + if not symbol or symbol in get_blocked_symbols(): continue volume = float(row.get('quote_volume') or 0) price = float(row.get('last_close') or 0) @@ -1508,7 +1499,7 @@ def get_position_market_ratio(self, limit=10, sort_order='desc', update=None): ratio_data = [] for coin in coinglass_data: symbol = coin.get('symbol', '') - if not symbol or symbol in self.blocked_symbols: + if not symbol or symbol in get_blocked_symbols(): continue # 使用持仓市值比字段 @@ -1590,7 +1581,7 @@ def get_volume_market_ratio(self, limit=10, sort_order='desc', update=None): ratio_data = [] for coin in coinglass_data: symbol = coin.get('symbol', '') - if not symbol or symbol in self.blocked_symbols: + if not symbol or symbol in get_blocked_symbols(): continue # 计算交易量/市值比 @@ -1682,7 +1673,7 @@ def get_volume_oi_ratio(self, limit=10, sort_order='desc', update=None): ratio_data = [] for coin in coinglass_data: symbol = coin.get('symbol', '') - if not symbol or symbol in self.blocked_symbols: + if not symbol or symbol in get_blocked_symbols(): continue # 使用持仓交易量比字段的倒数 @@ -1975,7 +1966,7 @@ def get_futures_money_flow(self, limit=10, period='24h', sort_order='desc', flow rows = [] for row in raw_rows: symbol = row.get('symbol', '') - if not symbol or symbol in self.blocked_symbols: + if not symbol or symbol in get_blocked_symbols(): continue net_flow = float(row.get('net_quote_flow') or 0) buy_quote = float(row.get('buy_quote') or 0) @@ -2060,7 +2051,7 @@ def get_spot_money_flow(self, limit=10, period='24h', sort_order='desc', flow_ty rows = [] for row in raw_rows: symbol = row.get('symbol', '') - if not symbol or symbol in self.blocked_symbols: + if not symbol or symbol in get_blocked_symbols(): continue net_flow = float(row.get('net_quote_flow') or 0) buy_quote = float(row.get('buy_quote') or 0) @@ -2255,21 +2246,10 @@ def __init__(self): self.cache_file_secondary = CACHE_FILE_SECONDARY self._current_cache_file = self.cache_file_primary # 当前使用的缓存文件 self._is_updating = False # 是否正在更新缓存 - # 需要屏蔽的币种列表(从全局配置读取) - self.blocked_symbols = BLOCKED_SYMBOLS self.metric_service = BINANCE_DB_METRIC_SERVICE if self.metric_service is None: logger.warning("⚠️ 币安数据库指标服务未就绪,部分排行榜将回退至缓存逻辑") - # 初始化信号格式化器 - try: - SignalFormatter = _load_signal_formatter() - self.signal_formatter = SignalFormatter() - logger.info("✅ 信号格式化器初始化成功") - except Exception as e: - logger.error(f"❌ 信号格式化器初始化失败: {e}") - self.signal_formatter = None - def filter_blocked_symbols(self, data_list): """过滤掉被屏蔽的币种""" if not data_list: @@ -2278,7 +2258,7 @@ def filter_blocked_symbols(self, data_list): filtered_data = [] for item in data_list: symbol = item.get('symbol', '') - if symbol not in self.blocked_symbols: + if symbol not in get_blocked_symbols(): filtered_data.append(item) return filtered_data @@ -2934,7 +2914,7 @@ def get_active_symbols(self, force_refresh=False): if (symbol_info['status'] == 'TRADING' and symbol_info['symbol'].endswith('USDT') and symbol_info['contractType'] == 'PERPETUAL' and - symbol_info['symbol'] not in self.blocked_symbols): + symbol_info['symbol'] not in get_blocked_symbols()): active_symbols.append(symbol_info['symbol']) # 获取24小时交易数据进行排序 @@ -2986,7 +2966,7 @@ def get_active_symbols(self, force_refresh=False): 'ARPAUSDT', 'LPTUSDT', 'ENSUSDT', 'PEOPLEUSDT', 'ROSEUSDT' ] # 过滤掉被屏蔽的币种 - filtered_symbols = [symbol for symbol in default_symbols if symbol not in self.blocked_symbols] + filtered_symbols = [symbol for symbol in default_symbols if symbol not in get_blocked_symbols()] self._active_symbols = filtered_symbols logger.info(f"使用默认币种列表: {len(filtered_symbols)} 个币种") return filtered_symbols @@ -3002,12 +2982,12 @@ def compute_market_sentiment_data(self): return None # 计算市场情绪指标 - filtered_price = [item for item in price_data if item['symbol'].endswith('USDT') and item['symbol'] not in self.blocked_symbols] + filtered_price = [item for item in price_data if item['symbol'].endswith('USDT') and item['symbol'] not in get_blocked_symbols()] total_coins = len(filtered_price) rising_coins = len([item for item in filtered_price if float(item['priceChangePercent']) > 0]) # 计算资金费率情绪 - filtered_funding = [item for item in funding_data if item['symbol'].endswith('USDT') and item['symbol'] not in self.blocked_symbols] + filtered_funding = [item for item in funding_data if item['symbol'].endswith('USDT') and item['symbol'] not in get_blocked_symbols()] avg_funding_rate = sum([float(item['lastFundingRate']) for item in filtered_funding]) / len(filtered_funding) if filtered_funding else 0 return { @@ -3031,7 +3011,7 @@ def compute_top_movers_data(self, move_type='gainers'): # 过滤数据 filtered_data = [ item for item in price_data - if item['symbol'].endswith('USDT') and float(item['quoteVolume']) > 1000000 and item['symbol'] not in self.blocked_symbols + if item['symbol'].endswith('USDT') and float(item['quoteVolume']) > 1000000 and item['symbol'] not in get_blocked_symbols() ] # 排序 @@ -3087,7 +3067,7 @@ def fetch_open_interest_hist_data(self, period='24h'): """获取持仓量历史数据 - 支持不同时间周期""" try: # 主流币种,过滤掉被屏蔽的币种 - major_symbols = [symbol for symbol in ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'ADAUSDT', 'XRPUSDT', 'SOLUSDT', 'DOGEUSDT', 'DOTUSDT'] if symbol not in self.blocked_symbols] + major_symbols = [symbol for symbol in ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'ADAUSDT', 'XRPUSDT', 'SOLUSDT', 'DOGEUSDT', 'DOTUSDT'] if symbol not in get_blocked_symbols()] hist_data = [] # 周期映射 @@ -3125,7 +3105,7 @@ def fetch_long_short_ratio_data(self, period='1d'): """获取多空比数据 - 改进版本""" try: # 获取主流币种的多空比数据,过滤掉被屏蔽的币种 - major_symbols = [symbol for symbol in ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'ADAUSDT', 'XRPUSDT'] if symbol not in self.blocked_symbols] + major_symbols = [symbol for symbol in ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'ADAUSDT', 'XRPUSDT'] if symbol not in get_blocked_symbols()] ratio_data = [] for symbol in major_symbols: @@ -3163,7 +3143,7 @@ def fetch_liquidation_data(self): liquidation_risks = [] for item in price_data: - if not item.get('symbol', '').endswith('USDT') or item.get('symbol', '') in self.blocked_symbols: + if not item.get('symbol', '').endswith('USDT') or item.get('symbol', '') in get_blocked_symbols(): continue try: @@ -3361,7 +3341,7 @@ def get_position_ranking(self, limit=10, sort_order='desc', period='24h', sort_f for item in futures_data: try: symbol = item.get('symbol', '') - if not symbol or symbol in self.blocked_symbols: + if not symbol or symbol in get_blocked_symbols(): continue # 获取基础持仓量数据 @@ -3665,6 +3645,223 @@ async def button_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): await lang_command(update, context) return + # ============================================================================= + # 配置管理回调 (env_*) - 为"最糟糕的用户"设计 + # 原则:3步内完成、即时反馈、友好文案、不让用户迷路 + # ============================================================================= + if button_data.startswith("env_"): + from bot.env_manager import ( + get_editable_configs_by_category, CONFIG_CATEGORIES, + get_config, set_config, EDITABLE_CONFIGS, FRIENDLY_MESSAGES + ) + await query.answer() + + # 分类按钮 env_cat_ + if button_data.startswith("env_cat_"): + category = button_data.replace("env_cat_", "") + cat_info = CONFIG_CATEGORIES.get(category, {}) + configs = get_editable_configs_by_category().get(category, []) + + if not configs: + await query.edit_message_text("🤔 这个分类暂时没有可配置的项目") + return + + # 友好的分类标题和说明 + lines = [ + f"{cat_info.get('icon', '⚙️')} *{cat_info.get('name', category)}*", + f"_{cat_info.get('desc', '')}_\n", + ] + + buttons = [] + for cfg in configs: + config_info = EDITABLE_CONFIGS.get(cfg["key"], {}) + icon = config_info.get("icon", "⚙️") + name = config_info.get("name", cfg["key"]) + value = cfg["value"] + + # 格式化显示值 + if not value: + display_value = "未设置" + elif len(value) > 15: + display_value = value[:12] + "..." + else: + # 对于选项类型,显示友好标签 + options = config_info.get("options", []) + if options and isinstance(options[0], dict): + for opt in options: + if opt["value"] == value: + display_value = opt["label"] + break + else: + display_value = value + else: + display_value = value + + hot_icon = "🚀" if cfg["hot_reload"] else "⏳" + lines.append(f"{icon} {name.split(' ', 1)[-1]}:{display_value} {hot_icon}") + + # 按钮只显示简短名称 + btn_text = name.split(' ', 1)[-1] if ' ' in name else name + buttons.append(InlineKeyboardButton( + f"✏️ {btn_text}", + callback_data=f"env_edit_{cfg['key']}" + )) + + lines.append("\n🚀 = 立即生效 ⏳ = 重启生效") + + # 每行 1 个按钮,更清晰 + keyboard_rows = [[btn] for btn in buttons] + keyboard_rows.append([InlineKeyboardButton("⬅️ 返回配置中心", callback_data="env_back")]) + + await query.edit_message_text( + "\n".join(lines), + reply_markup=InlineKeyboardMarkup(keyboard_rows), + parse_mode='Markdown' + ) + return + + # 编辑按钮 env_edit_ + if button_data.startswith("env_edit_"): + key = button_data.replace("env_edit_", "") + config_info = EDITABLE_CONFIGS.get(key, {}) + current_value = get_config(key) or "" + + name = config_info.get("name", key) + desc = config_info.get("desc", "") + help_text = config_info.get("help", "") + category = config_info.get("category", "symbols") + + # 如果有预设选项,显示友好的选项按钮 + options = config_info.get("options") + if options: + buttons = [] + # 新格式选项 [{value, label, detail}, ...] + if isinstance(options[0], dict): + for opt in options: + is_current = (opt["value"] == current_value) + prefix = "✅ " if is_current else "" + label = opt.get("label", opt["value"]) + buttons.append(InlineKeyboardButton( + f"{prefix}{label}", + callback_data=f"env_set_{key}_{opt['value']}" + )) + else: + # 旧格式选项 ["a", "b", ...] + for opt in options: + prefix = "✅ " if opt == current_value else "" + buttons.append(InlineKeyboardButton( + f"{prefix}{opt}", + callback_data=f"env_set_{key}_{opt}" + )) + + # 每行 1-2 个按钮 + if len(buttons) <= 3: + keyboard_rows = [[btn] for btn in buttons] + else: + keyboard_rows = [buttons[i:i+2] for i in range(0, len(buttons), 2)] + keyboard_rows.append([InlineKeyboardButton("⬅️ 返回", callback_data=f"env_cat_{category}")]) + + # 友好的编辑界面 + text = f"✏️ *{name}*\n\n" + text += f"{desc}\n\n" + if current_value: + text += f"📍 当前:`{current_value}`\n\n" + else: + text += f"📍 当前:未设置\n\n" + text += "👇 点击选择:" + + await query.edit_message_text( + text, + reply_markup=InlineKeyboardMarkup(keyboard_rows), + parse_mode='Markdown' + ) + else: + # 无预设选项,提示用户手动输入 + placeholder = config_info.get("placeholder", "") + context.user_data["env_editing_key"] = key + + text = f"✏️ *{name}*\n\n" + text += f"{desc}\n\n" + if help_text: + text += f"💡 {help_text}\n\n" + if current_value: + text += f"📍 当前值:`{current_value}`\n\n" + else: + text += f"📍 当前值:未设置\n\n" + text += "📝 请直接发送新的值:\n" + if placeholder: + text += f"_例如:{placeholder}_" + + keyboard_rows = [ + [InlineKeyboardButton("🗑️ 清空此项", callback_data=f"env_clear_{key}")], + [InlineKeyboardButton("⬅️ 返回", callback_data=f"env_cat_{category}")], + ] + + await query.edit_message_text( + text, + reply_markup=InlineKeyboardMarkup(keyboard_rows), + parse_mode='Markdown' + ) + return + + # 清空配置 env_clear_ + if button_data.startswith("env_clear_"): + key = button_data.replace("env_clear_", "") + success, msg = set_config(key, "") + config_info = EDITABLE_CONFIGS.get(key, {}) + category = config_info.get("category", "symbols") + + # 添加返回按钮 + keyboard = InlineKeyboardMarkup([[ + InlineKeyboardButton("👍 好的", callback_data=f"env_cat_{category}") + ]]) + await query.edit_message_text(msg, reply_markup=keyboard, parse_mode='Markdown') + return + + # 设置选项 env_set__ + if button_data.startswith("env_set_"): + parts = button_data.replace("env_set_", "").split("_", 1) + if len(parts) == 2: + key, value = parts + success, msg = set_config(key, value) + config_info = EDITABLE_CONFIGS.get(key, {}) + category = config_info.get("category", "symbols") + + # 成功后提供返回按钮 + keyboard = InlineKeyboardMarkup([[ + InlineKeyboardButton("👍 好的", callback_data=f"env_cat_{category}") + ]]) + await query.edit_message_text(msg, reply_markup=keyboard, parse_mode='Markdown') + return + + # 返回主配置菜单 + if button_data == "env_back": + # 按优先级排序分类 + sorted_cats = sorted(CONFIG_CATEGORIES.items(), key=lambda x: x[1].get("priority", 99)) + + text = "⚙️ *配置中心*\n\n" + text += "在这里可以调整 Bot 的各项设置~\n\n" + text += "👇 选择要配置的类别:" + + buttons = [] + for cat_id, cat_info in sorted_cats: + icon = cat_info.get("icon", "⚙️") + name = cat_info.get("name", cat_id) + buttons.append(InlineKeyboardButton( + f"{icon} {name.replace(icon, '').strip()}", + callback_data=f"env_cat_{cat_id}" + )) + + # 每行 2 个按钮 + keyboard_rows = [buttons[i:i+2] for i in range(0, len(buttons), 2)] + keyboard_rows.append([InlineKeyboardButton("🏠 返回主菜单", callback_data="main_menu")]) + + await query.edit_message_text( + text, + reply_markup=InlineKeyboardMarkup(keyboard_rows), + parse_mode='Markdown' + ) + # 语言切换 if button_data.startswith("set_lang_"): new_lang = button_data.replace("set_lang_", "") @@ -5028,6 +5225,115 @@ async def lang_command(update: Update, context: ContextTypes.DEFAULT_TYPE): await update.message.reply_text(msg, reply_markup=reply_keyboard) await update.message.reply_text(main_text, reply_markup=main_keyboard) + +# ============================================================================= +# /env 命令 - 配置管理(为"最糟糕的用户"设计) +# ============================================================================= +async def env_command(update: Update, context: ContextTypes.DEFAULT_TYPE): + """配置管理命令 /env - 友好的可视化配置界面""" + from bot.env_manager import ( + CONFIG_CATEGORIES, get_config, set_config, validate_config_value, EDITABLE_CONFIGS + ) + + args = context.args if context.args else [] + + # /env - 显示友好的配置中心(主入口) + if not args: + # 按优先级排序分类 + sorted_cats = sorted(CONFIG_CATEGORIES.items(), key=lambda x: x[1].get("priority", 99)) + + text = "⚙️ *配置中心*\n\n" + text += "👋 在这里可以轻松调整 Bot 的各项设置\n\n" + text += "👇 选择要配置的类别:" + + # 构建分类按钮,带图标和描述 + buttons = [] + for cat_id, cat_info in sorted_cats: + icon = cat_info.get("icon", "⚙️") + name = cat_info.get("name", cat_id).replace(icon, "").strip() + buttons.append(InlineKeyboardButton( + f"{icon} {name}", + callback_data=f"env_cat_{cat_id}" + )) + + # 每行 2 个按钮,更友好的布局 + keyboard_rows = [buttons[i:i+2] for i in range(0, len(buttons), 2)] + keyboard_rows.append([InlineKeyboardButton("🏠 返回主菜单", callback_data="main_menu")]) + keyboard = InlineKeyboardMarkup(keyboard_rows) + + await update.message.reply_text(text, reply_markup=keyboard, parse_mode='Markdown') + return + + # /env get - 获取配置值(保留命令行方式,但用友好文案) + if args[0].lower() == "get" and len(args) >= 2: + key = args[1].upper() + config_info = EDITABLE_CONFIGS.get(key, {}) + config_name = config_info.get("name", key) + value = get_config(key) + + if value is not None: + # 敏感配置脱敏显示 + if "TOKEN" in key or "SECRET" in key or "PASSWORD" in key: + display_value = value[:4] + "****" + value[-4:] if len(value) > 8 else "****" + else: + display_value = value + await update.message.reply_text( + f"📋 *{config_name}*\n\n当前值:`{display_value}`", + parse_mode='Markdown' + ) + else: + await update.message.reply_text( + f"📋 *{config_name}*\n\n当前值:未设置", + parse_mode='Markdown' + ) + return + + # /env set - 设置配置值 + if args[0].lower() == "set" and len(args) >= 3: + key = args[1].upper() + value = " ".join(args[2:]) + + # 验证配置值 + valid, msg = validate_config_value(key, value) + if not valid: + await update.message.reply_text(msg, parse_mode='Markdown') + return + + # 设置配置 + success, result_msg = set_config(key, value) + await update.message.reply_text(result_msg, parse_mode='Markdown') + return + + # /env list - 列出可配置项 + if args[0].lower() == "list": + lines = ["📋 *可配置的项目*\n"] + for key, info in EDITABLE_CONFIGS.items(): + icon = info.get("icon", "⚙️") + name = info.get("name", key) + hot = "🚀" if info.get("hot_reload") else "⏳" + lines.append(f"{icon} {name} {hot}") + lines.append("\n🚀 = 立即生效 ⏳ = 重启生效") + await update.message.reply_text("\n".join(lines), parse_mode='Markdown') + return + + # 帮助信息 - 友好版 + help_text = """⚙️ *配置帮助* + +最简单的方式:直接发送 `/env`,然后点击按钮操作~ + +如果你更喜欢命令行: + +• `/env` - 打开配置中心 +• `/env list` - 查看所有可配置项 +• `/env get 配置名` - 查看某个配置 +• `/env set 配置名 值` - 修改配置 + +💡 *小技巧* +发送 `/env` 后点按钮更方便哦! +""" + await update.message.reply_text(help_text, parse_mode='Markdown') + + async def vol_command(update: Update, context: ContextTypes.DEFAULT_TYPE): """交易量数据查询指令 /vol""" if not _is_command_allowed(update): @@ -5475,6 +5781,29 @@ async def handle_keyboard_message(update: Update, context: ContextTypes.DEFAULT_ message_text = update.message.text lang = _resolve_lang(update) + # ============================================================================= + # 处理配置编辑的用户输入 + # ============================================================================= + if context.user_data.get("env_editing_key"): + from bot.env_manager import set_config, validate_config_value, EDITABLE_CONFIGS + key = context.user_data.pop("env_editing_key") + + if message_text.strip() in ("取消", "cancel", "Cancel"): + await update.message.reply_text("❌ 已取消修改") + return + + value = message_text.strip() + valid, msg = validate_config_value(key, value) + if not valid: + await update.message.reply_text(f"❌ 验证失败: {msg}") + # 重新设置编辑状态让用户重试 + context.user_data["env_editing_key"] = key + return + + success, result_msg = set_config(key, value) + await update.message.reply_text(result_msg, parse_mode='Markdown') + return + if user_handler is None: logger.warning("user_handler 未初始化") return @@ -6183,6 +6512,8 @@ async def log_error(update, context): logger.info("✅ /vis 命令处理器已注册") application.add_handler(CommandHandler("lang", lang_command)) logger.info("✅ /lang 命令处理器已注册") + application.add_handler(CommandHandler("env", env_command)) + logger.info("✅ /env 命令处理器已注册") # 保留旧命令兼容性 application.add_handler(CommandHandler("stats", user_command)) @@ -6245,179 +6576,6 @@ async def send_signal(user_id: int, text: str, reply_markup): traceback.print_exc() -def add_signal_formatting_to_bot(): - """为TradeCatBot类添加信号格式化方法""" - - def format_signal_message(self, signal_type: str, symbol: str, alert_value: float) -> str: - """格式化信号消息""" - try: - if not self.signal_formatter: - return _t("error.signal_not_init", None) - - result = None - if signal_type == "funding_rate": - result = self.signal_formatter.format_funding_rate_signal(symbol, alert_value) - elif signal_type == "open_interest": - result = self.signal_formatter.format_open_interest_signal(symbol, alert_value) - elif signal_type == "rsi": - result = self.signal_formatter.format_rsi_signal(symbol, alert_value) - else: - return f"❌ 未知信号类型: {signal_type}" - - # 如果信号格式化函数返回None,表示数据不可用,返回None而不是错误消息 - return result - - except Exception as e: - logger.error(f"格式化信号消息错误: {e}") - return None # 异常时也返回None而不是错误消息 - - def send_formatted_signal(self, signal_type: str, symbol: str, alert_value: float, chat_id: str = None): - """发送格式化的信号消息""" - try: - message = self.format_signal_message(signal_type, symbol, alert_value) - - # 只有在消息不为None时才发送 - if message: - if chat_id: - # 发送到指定聊天(这里需要实际的发送实现) - logger.info(f"发送信号到 {chat_id}: {signal_type} - {symbol}") - # 实际发送逻辑需要根据具体的bot实现来添加 - print(f"📡 发送信号到 {chat_id}:\n{message}") - else: - # 发送到所有订阅用户(这里需要实际的广播实现) - logger.info(f"广播信号: {signal_type} - {symbol}") - # 实际广播逻辑需要根据具体的bot实现来添加 - print(f"📡 广播信号:\n{message}") - else: - logger.debug(f"📊 跳过 {symbol} 信号发送,数据不可用") - - except Exception as e: - logger.error(f"发送格式化信号错误: {e}") - - def get_formatted_signal_preview(self, signal_type: str, symbol: str, alert_value: float) -> str: - """获取格式化信号预览""" - try: - result = self.format_signal_message(signal_type, symbol, alert_value) - if result is None: - return _t("data.temporarily_unavailable", None) - return result - except Exception as e: - logger.error(f"获取信号预览错误: {e}") - return _t("data.temporarily_unavailable", None) - - # 添加发送消息的方法 - async def send_message_to_user(self, user_id: int, message: str, parse_mode: str = 'HTML'): - """发送消息给指定用户""" - try: - # 这里需要实际的Telegram Bot API实现 - # 如果bot有telegram app实例,使用它 - if hasattr(self, 'app') and self.app: - await self.app.bot.send_message( - chat_id=user_id, - text=message, - parse_mode=parse_mode - ) - logger.info(f"✅ 消息发送成功给用户 {user_id}") - else: - # 如果没有app实例,使用直接的Bot API调用 - import requests - BOT_TOKEN = _require_env('BOT_TOKEN', required=True) - url = f"https://api.telegram.org/bot{BOT_TOKEN}/sendMessage" - payload = { - 'chat_id': user_id, - 'text': message, - 'parse_mode': parse_mode - } - # 配置SSL验证 - verify_ssl = certifi.where() if CERTIFI_AVAILABLE else True - response = requests.post(url, json=payload, timeout=10, verify=verify_ssl) - if response.status_code == 200: - logger.info(f"✅ 消息发送成功给用户 {user_id}") - else: - logger.error(f"❌ 消息发送失败: {response.status_code}") - except Exception as e: - logger.error(f"❌ 发送消息给用户 {user_id} 失败: {e}") - raise e - - async def send_signal_to_user(self, user_id: int, signal_type: str, symbol: str, alert_value: float, custom_message: str = None): - """发送格式化信号给指定用户(带GIF动画)""" - try: - # 如果提供了自定义消息,使用自定义消息,否则格式化信号消息 - if custom_message: - message = custom_message - else: - message = self.format_signal_message(signal_type, symbol, alert_value) - if not message: - logger.warning(f"无法格式化信号 {signal_type} - {symbol},跳过发送") - return - - # 根据信号类型选择对应的GIF文件 - gif_file_map = { - 'funding_rate': str((ANIMATION_DIR / '狙击信号.gif.mp4').resolve()), - 'open_interest': str((ANIMATION_DIR / '趋势信号.gif.mp4').resolve()), - 'rsi': str((ANIMATION_DIR / '情绪信号.gif.mp4').resolve()) - } - - gif_file = gif_file_map.get(signal_type) - - # 发送消息(带GIF动画) - if gif_file and os.path.exists(gif_file): - try: - if hasattr(self, 'app') and self.app: - with open(gif_file, 'rb') as gif: - await self.app.bot.send_animation( - chat_id=user_id, - animation=gif, - caption=message, # 将信号文本作为GIF的说明文字 - parse_mode='HTML', - duration=3, # 动画时长 - width=320, # 动画宽度 - height=240 # 动画高度 - ) - logger.info(f"✅ 成功发送带GIF的 {signal_type} 信号给用户 {user_id}") - else: - # 如果没有app实例,回退到纯文本消息 - await self.send_message_to_user(user_id, message, 'HTML') - logger.info(f"✅ 成功发送 {signal_type} 信号给用户 {user_id} (纯文本)") - except Exception as gif_error: - logger.warning(f"⚠️ 发送GIF失败,使用纯文本: {gif_error}") - # GIF发送失败时,回退到纯文本消息 - await self.send_message_to_user(user_id, message, 'HTML') - else: - # 没有GIF文件时,发送纯文本消息 - await self.send_message_to_user(user_id, message, 'HTML') - logger.info(f"✅ 成功发送 {signal_type} 信号给用户 {user_id}") - - except Exception as e: - logger.error(f"❌ 发送信号给用户 {user_id} 失败: {e}") - raise e - - async def start_bot(self): - """启动机器人(占位符方法)""" - logger.info("✅ 机器人启动完成") - return True - - async def stop_bot(self): - """停止机器人(占位符方法)""" - logger.info("🛑 机器人已停止") - return True - - # 将方法添加到TradeCatBot类 - TradeCatBot.format_signal_message = format_signal_message - TradeCatBot.send_formatted_signal = send_formatted_signal - TradeCatBot.get_formatted_signal_preview = get_formatted_signal_preview - TradeCatBot.send_message_to_user = send_message_to_user - TradeCatBot.send_signal_to_user = send_signal_to_user - TradeCatBot.start_bot = start_bot - TradeCatBot.stop_bot = stop_bot - - logger.info("✅ 信号格式化和发送方法已添加到TradeCatBot类") - -# 调用函数添加方法 -add_signal_formatting_to_bot() - - - if __name__ == "__main__": # 使用完整启动模式,包含所有功能 main() diff --git a/services/telegram-service/src/bot/env_manager.py b/services/telegram-service/src/bot/env_manager.py new file mode 100644 index 00000000..f9db760a --- /dev/null +++ b/services/telegram-service/src/bot/env_manager.py @@ -0,0 +1,486 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +环境变量管理模块 - 通过 Bot 管理 .env 配置 + +设计原则(为"最糟糕的用户"设计): +- 所有操作最多 3 步 +- 友好的文案,禁止责备性词汇 +- 即时反馈,让用户知道发生了什么 +- 主动提供帮助和示例 +""" + +import os +import re +import logging +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + +# 项目根目录 +_PROJECT_ROOT = Path(__file__).parents[4] +ENV_PATH = _PROJECT_ROOT / "config" / ".env" + +# ============================================================================= +# 配置白名单(允许通过 Bot 修改) +# 设计原则:用人话描述,提供清晰的帮助信息 +# ============================================================================= +EDITABLE_CONFIGS = { + # 代理设置 - 最常见的配置需求 + "HTTP_PROXY": { + "name": "🌐 HTTP 代理", + "desc": "访问 Telegram/Binance 时使用的代理", + "help": "格式:http://IP:端口\n例如:http://127.0.0.1:7890", + "category": "proxy", + "hot_reload": False, + "placeholder": "http://127.0.0.1:7890", + "icon": "🌐", + }, + "HTTPS_PROXY": { + "name": "🔒 HTTPS 代理", + "desc": "通常和 HTTP 代理设置相同即可", + "help": "格式:http://IP:端口\n大多数情况下填和 HTTP 代理一样的值", + "category": "proxy", + "hot_reload": False, + "placeholder": "http://127.0.0.1:7890", + "icon": "🔒", + }, + + # 币种管理 - 核心配置 + "SYMBOLS_GROUPS": { + "name": "💰 监控币种", + "desc": "选择要监控的币种范围", + "help": "选择一个预设分组,或输入自定义", + "category": "symbols", + "hot_reload": True, + "options": [ + {"value": "main4", "label": "🔥 主流4币", "detail": "BTC/ETH/SOL/BNB"}, + {"value": "main6", "label": "⭐ 主流6币", "detail": "+XRP/DOGE"}, + {"value": "main20", "label": "📊 主流20币", "detail": "常见主流币"}, + {"value": "auto", "label": "🤖 智能选择", "detail": "自动选高交易量币"}, + {"value": "all", "label": "🌍 全部币种", "detail": "600+币种,资源消耗大"}, + ], + "icon": "💰", + }, + "SYMBOLS_EXTRA": { + "name": "➕ 额外添加", + "desc": "在分组基础上额外添加的币种", + "help": "输入币种代码,多个用逗号分隔\n例如:PEPEUSDT,WIFUSDT", + "category": "symbols", + "hot_reload": True, + "placeholder": "PEPEUSDT,WIFUSDT", + "icon": "➕", + }, + "SYMBOLS_EXCLUDE": { + "name": "➖ 排除币种", + "desc": "从分组中排除这些币种", + "help": "输入不想监控的币种\n例如:LUNAUSDT", + "category": "symbols", + "hot_reload": True, + "placeholder": "LUNAUSDT", + "icon": "➖", + }, + "BLOCKED_SYMBOLS": { + "name": "🚫 屏蔽显示", + "desc": "这些币种不会出现在排行榜中", + "help": "用于隐藏异常或不想看到的币种\n例如:BNXUSDT,ALPACAUSDT", + "category": "symbols", + "hot_reload": True, + "placeholder": "BNXUSDT,ALPACAUSDT", + "icon": "🚫", + }, + + # 功能开关 - 简单的开/关 + "DISABLE_SINGLE_TOKEN_QUERY": { + "name": "🔍 单币查询", + "desc": "发送 BTC! 查询单币详情", + "help": "开启后可以发送如 BTC! 来查询单个币种", + "category": "features", + "hot_reload": True, + "options": [ + {"value": "0", "label": "✅ 开启", "detail": "可用单币查询"}, + {"value": "1", "label": "⏸️ 关闭", "detail": "节省资源"}, + ], + "icon": "🔍", + "invert_display": True, # 0=开启,显示逻辑反转 + }, + "BINANCE_API_DISABLED": { + "name": "📡 实时数据", + "desc": "从 Binance 获取实时价格", + "help": "关闭后使用缓存数据,开启需要代理", + "category": "features", + "hot_reload": True, + "options": [ + {"value": "0", "label": "✅ 开启", "detail": "实时价格,需代理"}, + {"value": "1", "label": "⏸️ 关闭", "detail": "使用缓存数据"}, + ], + "icon": "📡", + "invert_display": True, + }, + + # 展示设置 + "DEFAULT_LOCALE": { + "name": "🌍 界面语言", + "desc": "Bot 显示的语言", + "help": "切换后立即生效", + "category": "display", + "hot_reload": True, + "options": [ + {"value": "zh-CN", "label": "🇨🇳 中文", "detail": ""}, + {"value": "en", "label": "🇺🇸 English", "detail": ""}, + ], + "icon": "🌍", + }, + "SNAPSHOT_HIDDEN_FIELDS": { + "name": "🙈 隐藏字段", + "desc": "单币快照中不显示的字段", + "help": "输入要隐藏的字段名,用逗号分隔", + "category": "display", + "hot_reload": True, + "placeholder": "最近翻转时间", + "icon": "🙈", + }, + + # 卡片开关 + "CARDS_ENABLED": { + "name": "📊 启用卡片", + "desc": "只显示这些排行卡片", + "help": "留空显示全部,或输入要显示的卡片名", + "category": "cards", + "hot_reload": True, + "placeholder": "资金流向,MACD", + "icon": "📊", + }, + "CARDS_DISABLED": { + "name": "🚫 禁用卡片", + "desc": "不显示这些排行卡片", + "help": "输入要隐藏的卡片名,用逗号分隔", + "category": "cards", + "hot_reload": True, + "placeholder": "K线形态", + "icon": "🚫", + }, + + # 指标开关 + "INDICATORS_ENABLED": { + "name": "📈 启用指标", + "desc": "只计算这些指标", + "help": "留空计算全部,需重启生效", + "category": "indicators", + "hot_reload": False, + "placeholder": "macd,rsi", + "icon": "📈", + }, + "INDICATORS_DISABLED": { + "name": "🚫 禁用指标", + "desc": "不计算这些指标", + "help": "可节省资源,需重启生效", + "category": "indicators", + "hot_reload": False, + "placeholder": "k线形态", + "icon": "🚫", + }, +} + +# 只读配置(禁止修改) +READONLY_CONFIGS = { + "BOT_TOKEN", "DATABASE_URL", + "BINANCE_API_KEY", "BINANCE_API_SECRET", + "POSTGRES_PASSWORD", "POSTGRES_USER", +} + +# 配置分类 - 用户最关心的放前面 +CONFIG_CATEGORIES = { + "symbols": { + "name": "💰 币种管理", + "desc": "设置要监控哪些币种", + "icon": "💰", + "priority": 1, + }, + "features": { + "name": "⚡ 功能开关", + "desc": "开启或关闭某些功能", + "icon": "⚡", + "priority": 2, + }, + "proxy": { + "name": "🌐 网络代理", + "desc": "国内访问需要设置代理", + "icon": "🌐", + "priority": 3, + }, + "display": { + "name": "🎨 显示设置", + "desc": "语言、界面相关", + "icon": "🎨", + "priority": 4, + }, + "cards": { + "name": "📊 卡片管理", + "desc": "控制显示哪些排行卡片", + "icon": "📊", + "priority": 5, + }, + "indicators": { + "name": "📈 指标计算", + "desc": "控制计算哪些指标", + "icon": "📈", + "priority": 6, + }, +} + +# ============================================================================= +# 友好文案(禁止责备性词汇) +# ============================================================================= +FRIENDLY_MESSAGES = { + "save_success": "✨ 保存成功!", + "save_success_hot": "✨ 保存成功,已立即生效!", + "save_success_restart": "✨ 保存成功!重启后生效~", + "validation_hint": "💡 小提示:", + "input_prompt": "📝 请输入新的值:", + "current_value": "当前:", + "not_set": "未设置", + "back": "⬅️ 返回", + "cancel": "❌ 取消", + "confirm": "✅ 确认", + "clear": "🗑️ 清空", +} + + +def read_env() -> Dict[str, str]: + """读取 .env 文件为字典""" + result = {} + if not ENV_PATH.exists(): + logger.warning(f".env 文件不存在: {ENV_PATH}") + return result + + try: + for line in ENV_PATH.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip() + # 去除引号 + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + result[key] = value + except Exception as e: + logger.error(f"读取 .env 失败: {e}") + + return result + + +def read_env_raw() -> str: + """读取 .env 文件原始内容""" + if not ENV_PATH.exists(): + return "" + return ENV_PATH.read_text(encoding="utf-8") + + +def get_config(key: str) -> Optional[str]: + """获取单个配置值(优先环境变量,其次 .env 文件)""" + # 优先从当前环境变量获取 + value = os.environ.get(key) + if value is not None: + return value + # 其次从 .env 文件获取 + env_dict = read_env() + return env_dict.get(key) + + +def set_config(key: str, value: str) -> Tuple[bool, str]: + """ + 设置配置值 + + Returns: + (success, message) - 使用友好文案 + """ + config_info = EDITABLE_CONFIGS.get(key, {}) + config_name = config_info.get("name", key) + + # 检查是否允许修改(友好提示) + if key in READONLY_CONFIGS: + return False, f"🔒 {config_name} 是系统核心配置,需要在文件中手动修改哦" + + if key not in EDITABLE_CONFIGS: + return False, f"🤔 暂不支持修改 {key},如有需要请联系管理员" + + # 读取当前文件内容 + if not ENV_PATH.exists(): + return False, f"📁 配置文件还没准备好,请先完成初始化" + + try: + lines = ENV_PATH.read_text(encoding="utf-8").splitlines() + found = False + new_lines = [] + + for line in lines: + stripped = line.strip() + if stripped.startswith(f"{key}=") or stripped.startswith(f"{key} ="): + new_lines.append(f"{key}={value}") + found = True + else: + new_lines.append(line) + + if not found: + new_lines.append(f"{key}={value}") + + ENV_PATH.write_text("\n".join(new_lines) + "\n", encoding="utf-8") + os.environ[key] = value + + # 触发热更新,使用友好反馈 + if config_info.get("hot_reload"): + _trigger_hot_reload(key) + # 显示友好的值 + display_value = _format_display_value(key, value) + return True, f"✨ {config_name}\n\n已更新为:{display_value}\n\n🚀 立即生效!" + else: + display_value = _format_display_value(key, value) + return True, f"✨ {config_name}\n\n已更新为:{display_value}\n\n💡 重启后生效~" + + except PermissionError: + return False, f"😅 没有写入权限,请检查配置文件权限设置" + except Exception as e: + logger.error(f"写入 .env 失败: {e}") + return False, f"😅 保存时遇到了问题,请稍后再试\n\n技术信息:{e}" + + +def _format_display_value(key: str, value: str) -> str: + """格式化显示值,让用户更容易理解""" + config_info = EDITABLE_CONFIGS.get(key, {}) + options = config_info.get("options", []) + + # 如果是选项类型,显示选项标签 + if options and isinstance(options[0], dict): + for opt in options: + if opt.get("value") == value: + return f"{opt.get('label', value)}" + + # 空值友好显示 + if not value: + return "(已清空)" + + return f"`{value}`" + + +def _trigger_hot_reload(key: str): + """触发热更新""" + try: + if key in ("SYMBOLS_GROUPS", "SYMBOLS_EXTRA", "SYMBOLS_EXCLUDE"): + # 重置币种缓存 + from cards.data_provider import reset_symbols_cache + reset_symbols_cache() + logger.info(f"已重置币种缓存: {key}") + + if key == "BLOCKED_SYMBOLS": + # BLOCKED_SYMBOLS 通过动态获取,无需额外操作 + logger.info(f"已更新屏蔽币种: {key}") + + if key in ("CARDS_ENABLED", "CARDS_DISABLED"): + # 卡片注册表热更新 + from cards.registry import reload_card_config + reload_card_config() + logger.info(f"已重载卡片配置: {key}") + + except ImportError as e: + logger.warning(f"热更新模块导入失败: {e}") + except Exception as e: + logger.error(f"热更新失败: {e}") + + +def get_editable_configs_by_category() -> Dict[str, List[dict]]: + """按分类获取可编辑的配置""" + result = {cat: [] for cat in CONFIG_CATEGORIES} + + env_dict = read_env() + + for key, info in EDITABLE_CONFIGS.items(): + category = info.get("category", "other") + current_value = os.environ.get(key) or env_dict.get(key, "") + + result[category].append({ + "key": key, + "value": current_value, + "desc": info.get("desc", key), + "desc_en": info.get("desc_en", key), + "hot_reload": info.get("hot_reload", False), + "options": info.get("options"), + "example": info.get("example"), + }) + + return result + + +def get_config_summary() -> str: + """获取配置摘要(用于显示)""" + env_dict = read_env() + lines = [] + + for category, cat_info in CONFIG_CATEGORIES.items(): + configs = [c for c in EDITABLE_CONFIGS.items() if c[1].get("category") == category] + if not configs: + continue + + lines.append(f"\n{cat_info['name']}") + for key, info in configs: + value = os.environ.get(key) or env_dict.get(key, "") + display_value = value if len(value) < 30 else value[:27] + "..." + hot = "🔥" if info.get("hot_reload") else "🔄" + lines.append(f" {hot} {info['desc']}: {display_value or '(未设置)'}") + + return "\n".join(lines) + + +def validate_config_value(key: str, value: str) -> Tuple[bool, str]: + """ + 验证配置值 + 使用友好文案,告诉用户如何修正而不是责备 + """ + config_info = EDITABLE_CONFIGS.get(key) + if not config_info: + return False, "🤔 这个配置项暂不支持修改" + + # 允许清空 + if not value: + return True, "OK" + + # 检查选项限制 + options = config_info.get("options") + if options: + # 新格式:[{value, label}, ...] + if isinstance(options[0], dict): + valid_values = [opt["value"] for opt in options] + if value not in valid_values: + labels = [f"{opt['label']}" for opt in options] + return False, f"💡 请从以下选项中选择:\n" + "\n".join(labels) + # 旧格式:["a", "b", ...] + elif value not in options: + return False, f"💡 请从以下选项中选择:{', '.join(options)}" + + # 代理格式验证 + if key in ("HTTP_PROXY", "HTTPS_PROXY") and value: + if not re.match(r'^(http|https|socks5)://[\w\-\.]+:\d+$', value): + return False, ( + "💡 代理格式需要这样写:\n" + "• http://127.0.0.1:7890\n" + "• socks5://127.0.0.1:1080\n\n" + "请检查一下格式~" + ) + + # 币种格式验证 + if key in ("SYMBOLS_EXTRA", "SYMBOLS_EXCLUDE", "BLOCKED_SYMBOLS") and value: + symbols = [s.strip().upper() for s in value.split(",") if s.strip()] + invalid = [s for s in symbols if not re.match(r'^[A-Z0-9]+USDT$', s)] + if invalid: + return False, ( + f"💡 币种格式需要以 USDT 结尾\n\n" + f"• 正确:BTCUSDT, ETHUSDT\n" + f"• 你输入的:{', '.join(invalid)}\n\n" + f"请修改一下~" + ) + + return True, "OK" diff --git a/services/telegram-service/src/bot/signal_formatter.py b/services/telegram-service/src/bot/signal_formatter.py deleted file mode 100644 index bd22d781..00000000 --- a/services/telegram-service/src/bot/signal_formatter.py +++ /dev/null @@ -1,857 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -信号格式化系统 -基于CoinGlass数据实现三种信号类型: -1. 狙击信号(资金费率) -2. 趋势信号(持仓量) -3. 情绪信号(RSI) -并扩展对 TradingView 三个买卖信号(UT Bot / Supertrend / AlphaTrend)的实时推送格式化 -支持币安API获取资金费率数据 -""" - -import os -import json -import logging -import requests -from datetime import datetime, timezone, timedelta -from pathlib import Path -import sys -from typing import Dict, List, Any, Optional, Tuple, Iterable - -当前目录 = Path(__file__).resolve().parent -根目录 = 当前目录.parent -if str(根目录) not in sys.path: - sys.path.append(str(根目录)) - -# 导入智能格式化函数(避免循环依赖,失败则降级为简单格式) -try: - from main import smart_price_format, smart_percentage_format, smart_volume_format -except Exception: - def smart_price_format(price: float) -> str: - return f"{price:.4f}" - - def smart_percentage_format(value: float) -> str: - return f"{value:.2f}%" - - def smart_volume_format(volume: float) -> str: - return f"{volume:.0f}" - -logger = logging.getLogger(__name__) - -class BinanceAPIClient: - """币安API客户端 - 专门用于获取资金费率数据""" - - def __init__(self): - self.base_url = "https://fapi.binance.com" - self.session = requests.Session() - self.session.headers.update({ - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }) - self._exchange_info = None - self._symbols_cache = None - - def get_funding_rate(self, symbol: str = None) -> Optional[Dict]: - """获取资金费率数据""" - try: - url = f"{self.base_url}/fapi/v1/premiumIndex" - params = {} - if symbol: - params['symbol'] = symbol - - response = self.session.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - if symbol: - # 返回单个币种数据 - return data - else: - # 返回所有币种数据 - return {item['symbol']: item for item in data} - - except Exception as e: - logger.error(f"❌ 获取币安资金费率数据失败: {e}") - return None - - def get_24hr_ticker(self, symbol: str = None) -> Optional[Dict]: - """获取24小时价格变动数据""" - try: - url = f"{self.base_url}/fapi/v1/ticker/24hr" - params = {} - if symbol: - params['symbol'] = symbol - - response = self.session.get(url, params=params, timeout=10) - response.raise_for_status() - data = response.json() - - if symbol: - return data - else: - return {item['symbol']: item for item in data} - - except Exception as e: - logger.error(f"❌ 获取币安24小时数据失败: {e}") - return None - - def get_exchange_info(self) -> Optional[Dict]: - """获取币安交易所信息(包含所有交易对)""" - try: - if self._exchange_info is None: - url = f"{self.base_url}/fapi/v1/exchangeInfo" - response = self.session.get(url, timeout=10) - response.raise_for_status() - self._exchange_info = response.json() - logger.info(f"获取到 {len(self._exchange_info.get('symbols', []))} 个交易对信息") - return self._exchange_info - except Exception as e: - logger.error(f"❌ 获取币安交易所信息失败: {e}") - return None - - def get_all_symbols(self) -> List[str]: - """获取所有USDT交易对列表""" - try: - if self._symbols_cache is None: - exchange_info = self.get_exchange_info() - if exchange_info and 'symbols' in exchange_info: - # 筛选出状态为TRADING的USDT交易对 - usdt_symbols = [] - for symbol_info in exchange_info['symbols']: - symbol = symbol_info.get('symbol', '') - status = symbol_info.get('status', '') - if symbol.endswith('USDT') and status == 'TRADING': - usdt_symbols.append(symbol) - self._symbols_cache = usdt_symbols - logger.info(f"缓存了 {len(usdt_symbols)} 个USDT交易对") - else: - self._symbols_cache = [] - return self._symbols_cache or [] - except Exception as e: - logger.error(f"❌ 获取币安交易对列表失败: {e}") - return [] - -class SignalFormatter: - """信号格式化器""" - - def __init__(self, coinglass_data_dir: str = None): - """初始化信号格式化器""" - self.coinglass_data_dir = coinglass_data_dir or os.path.join(os.path.dirname(__file__), "data", "coinglass") - self.futures_data = [] - self.spot_data = [] - self.last_update_time = None - - # 初始化币安API客户端 - self.binance_client = BinanceAPIClient() - - self.load_data() - - def load_data(self): - """加载最新的CoinGlass数据""" - try: - if not os.path.exists(self.coinglass_data_dir): - logger.warning(f"CoinGlass数据目录不存在: {self.coinglass_data_dir}") - return - - # 获取最新的数据目录 - cache_dirs = [] - for item in os.listdir(self.coinglass_data_dir): - item_path = os.path.join(self.coinglass_data_dir, item) - if os.path.isdir(item_path): - cache_dirs.append(item) - - if not cache_dirs: - logger.warning("没有找到CoinGlass缓存目录") - return - - # 按时间排序,获取最新的 - cache_dirs.sort(reverse=True) - latest_cache_dir = os.path.join(self.coinglass_data_dir, cache_dirs[0]) - - # 读取futures数据 - futures_file = os.path.join(latest_cache_dir, "futures.json") - if os.path.exists(futures_file): - with open(futures_file, 'r', encoding='utf-8') as f: - self.futures_data = json.load(f) - logger.info(f"成功加载 {len(self.futures_data)} 个合约数据") - - # 读取spot数据 - spot_file = os.path.join(latest_cache_dir, "spot.json") - if os.path.exists(spot_file): - with open(spot_file, 'r', encoding='utf-8') as f: - self.spot_data = json.load(f) - logger.info(f"成功加载 {len(self.spot_data)} 个现货数据") - - self.last_update_time = datetime.now() - - except Exception as e: - logger.error(f"加载CoinGlass数据失败: {e}") - - def get_coin_data(self, symbol: str) -> Tuple[Optional[Dict], Optional[Dict]]: - """获取指定币种的合约和现货数据""" - futures_coin = None - spot_coin = None - - # 查找合约数据 - for coin in self.futures_data: - if coin.get('symbol', '').upper() == symbol.upper(): - futures_coin = coin - break - - # 查找现货数据 - for coin in self.spot_data: - if coin.get('symbol', '').upper() == symbol.upper(): - spot_coin = coin - break - - return futures_coin, spot_coin - - def get_binance_data(self, symbol: str) -> Dict[str, Any]: - """获取币安API数据(资金费率和价格数据)""" - binance_data = { - 'funding_rate': 0.0, - 'mark_price': 0.0, - 'price_24h_change': 0.0, - 'volume_24h': 0.0, - 'next_funding_time': 0 - } - - try: - # 转换币种符号:CoinGlass格式转币安格式 - binance_symbol = self.convert_to_binance_symbol(symbol) - - # 获取资金费率数据 - funding_data = self.binance_client.get_funding_rate(binance_symbol) - if funding_data: - binance_data['funding_rate'] = float(funding_data.get('lastFundingRate', 0)) - binance_data['mark_price'] = float(funding_data.get('markPrice', 0)) - binance_data['next_funding_time'] = funding_data.get('nextFundingTime', 0) - - # 获取24小时价格数据 - ticker_data = self.binance_client.get_24hr_ticker(binance_symbol) - if ticker_data: - binance_data['price_24h_change'] = float(ticker_data.get('priceChangePercent', 0)) - binance_data['volume_24h'] = float(ticker_data.get('quoteVolume', 0)) - # 如果mark_price为0,使用最新价格 - if binance_data['mark_price'] == 0: - binance_data['mark_price'] = float(ticker_data.get('lastPrice', 0)) - - except Exception as e: - logger.error(f"❌ 获取币安数据失败 {symbol}: {e}") - - return binance_data - - def convert_to_binance_symbol(self, coinglass_symbol: str) -> str: - """将CoinGlass符号转换为币安符号 - 智能匹配""" - # 移除可能的斜杠和空格 - symbol = coinglass_symbol.replace('/', '').replace(' ', '').upper() - - # 如果已经是USDT结尾,直接返回 - if symbol.endswith('USDT'): - return symbol - - # 获取所有币安交易对 - all_symbols = self.binance_client.get_all_symbols() - - # 常见的特殊映射(处理一些特殊情况) - special_mappings = { - '1000PEPE': '1000PEPEUSDT', - '1000SHIB': '1000SHIBUSDT', - '1000FLOKI': '1000FLOKIUSDT', - '1000BONK': '1000BONKUSDT', - '1000RATS': '1000RATSUSDT', - '1000SATS': '1000SATSUSDT', - 'BTCDOM': 'BTCDOMUSDT', - 'ETHDOM': 'ETHDOMUSDT' - } - - # 检查特殊映射 - if symbol in special_mappings: - target_symbol = special_mappings[symbol] - if target_symbol in all_symbols: - return target_symbol - - # 智能匹配算法 - possible_symbols = [ - f"{symbol}USDT", # 直接添加USDT - f"1000{symbol}USDT", # 某些meme币需要1000前缀 - ] - - # 检查可能的符号是否存在于币安 - for possible_symbol in possible_symbols: - if possible_symbol in all_symbols: - logger.info(f"匹配成功: {coinglass_symbol} -> {possible_symbol}") - return possible_symbol - - # 如果都找不到,尝试模糊匹配 - for binance_symbol in all_symbols: - # 移除USDT后缀进行比较 - base_symbol = binance_symbol.replace('USDT', '') - if base_symbol == symbol: - logger.info(f"模糊匹配成功: {coinglass_symbol} -> {binance_symbol}") - return binance_symbol - # 检查是否包含目标符号 - if symbol in base_symbol or base_symbol in symbol: - logger.info(f"部分匹配成功: {coinglass_symbol} -> {binance_symbol}") - return binance_symbol - - # 如果完全找不到,返回默认格式 - default_symbol = f"{symbol}USDT" - logger.warning(f"未找到匹配的币安交易对,使用默认格式: {coinglass_symbol} -> {default_symbol}") - return default_symbol - - def _format_amount_with_unit(self, amount: float) -> str: - """智能格式化金额显示,小金额使用K,大金额使用M,保留两位小数""" - if abs(amount) >= 1000: # 大于等于1000M,使用B单位 - return f"{amount/1000:.2f}B" if amount >= 0 else f"{amount/1000:.2f}B" - elif abs(amount) >= 1: # 大于等于1M,使用M单位 - return f"{amount:.2f}M" if amount >= 0 else f"{amount:.2f}M" - elif abs(amount) >= 0.001: # 大于等于0.001M(即1K),使用K单位 - return f"{amount*1000:.2f}K" if amount >= 0 else f"{amount*1000:.2f}K" - else: # 小于1K,直接显示原始金额 - return f"{amount*1000000:.2f}" if amount >= 0 else f"{amount*1000000:.2f}" - - def calculate_derived_indicators(self, futures_data: Dict, spot_data: Dict = None, binance_data: Dict = None) -> Dict: - """计算衍生指标""" - indicators = {} - - try: - # 基础数据 - 优先使用币安数据 - if binance_data: - price = binance_data.get('mark_price', 0) - funding_rate = binance_data.get('funding_rate', 0) - price_change_24h = binance_data.get('price_24h_change', 0) - volume_24h = binance_data.get('volume_24h', 0) - else: - price = futures_data.get('current_price', 0) - funding_rate = futures_data.get('avg_funding_rate_by_oi', 0) - price_change_24h = futures_data.get('price_change_percent_24h', 0) - volume_24h = futures_data.get('volume_change_usd_24h', 0) - - oi_usd = futures_data.get('open_interest_usd', 0) - oi_24h_change = futures_data.get('open_interest_change_percent_24h', 0) - - # 多空比例数据 - ls_ratio_1h = futures_data.get('long_short_ratio_1h', 1) - ls_ratio_4h = futures_data.get('long_short_ratio_4h', 1) - ls_ratio_24h = futures_data.get('long_short_ratio_24h', 1) - - # 爆仓数据 - liq_24h = futures_data.get('liquidation_usd_24h', 0) - long_liq_24h = futures_data.get('long_liquidation_usd_24h', 0) - short_liq_24h = futures_data.get('short_liquidation_usd_24h', 0) - - # 计算恐贪指数 (0-100) - fear_greed = 50 - if funding_rate > 0.01: - fear_greed += 30 - elif funding_rate < -0.01: - fear_greed -= 30 - - if oi_24h_change > 5: - fear_greed += 10 - elif oi_24h_change < -5: - fear_greed -= 10 - - fear_greed = max(0, min(100, fear_greed)) - - # 计算市场活跃度 - market_activity = min(100, abs(volume_24h) / 1000000) if volume_24h > 0 else 50 - - # 计算综合风险评分 - risk_score = 50 - if abs(funding_rate) > 0.01: - risk_score += 20 - if abs(oi_24h_change) > 10: - risk_score += 15 - if liq_24h > 10000000: # 1000万以上爆仓 - risk_score += 15 - risk_score = max(0, min(100, risk_score)) - - # 计算波动率 - price_changes = [ - futures_data.get('price_change_percent_1h', 0), - futures_data.get('price_change_percent_4h', 0), - price_change_24h - ] - volatility = sum(abs(x) for x in price_changes) / len(price_changes) - - # 计算动量趋势 - momentum = price_change_24h - - # 计算持仓效率 - oi_vol_ratio = futures_data.get('open_interest_volume_ratio', 0) - position_efficiency = min(100, oi_vol_ratio * 100) - - # 计算主力倾向 - if ls_ratio_24h > 1.2: - main_tendency = "多头主导" - elif ls_ratio_24h < 0.8: - main_tendency = "空头主导" - else: - main_tendency = "均衡" - - # 计算净流入(使用CoinGlass合约数据的多周期资金流向) - # 优先使用实际的多空成交量数据 - long_vol_1h = futures_data.get('long_volume_usd_1h', 0) - short_vol_1h = futures_data.get('short_volume_usd_1h', 0) - long_vol_4h = futures_data.get('long_volume_usd_4h', 0) - short_vol_4h = futures_data.get('short_volume_usd_4h', 0) - long_vol_24h = futures_data.get('long_volume_usd_24h', 0) - short_vol_24h = futures_data.get('short_volume_usd_24h', 0) - - # 方法1: 基于多空成交量差计算净流入 (主要方法) - if long_vol_1h > 0 or short_vol_1h > 0: - net_inflow_1h = (long_vol_1h - short_vol_1h) / 1000000 # 转换为百万 - else: - net_inflow_1h = 0 - - if long_vol_4h > 0 or short_vol_4h > 0: - net_inflow_4h = (long_vol_4h - short_vol_4h) / 1000000 # 转换为百万 - else: - net_inflow_4h = 0 - - if long_vol_24h > 0 or short_vol_24h > 0: - net_inflow_24h = (long_vol_24h - short_vol_24h) / 1000000 # 转换为百万 - else: - net_inflow_24h = 0 - - # 方法2: 如果没有多空成交量数据,使用持仓量变化结合价格变化估算 - if abs(net_inflow_1h) < 0.1 and abs(net_inflow_4h) < 0.1 and abs(net_inflow_24h) < 0.1: - oi_change_1h = futures_data.get('open_interest_change_usd_1h', 0) - oi_change_4h = futures_data.get('open_interest_change_usd_4h', 0) - oi_change_24h = futures_data.get('open_interest_change_usd_24h', 0) - - if oi_change_1h != 0: - # 持仓量增加且价格上涨 = 资金流入,持仓量增加且价格下跌 = 资金流出 - price_change_1h = futures_data.get('price_change_percent_1h', 0) - direction_factor = 1 if price_change_1h > 0 else -1 - net_inflow_1h = abs(oi_change_1h) * direction_factor / 1000000 - - if oi_change_4h != 0: - price_change_4h = futures_data.get('price_change_percent_4h', 0) - direction_factor = 1 if price_change_4h > 0 else -1 - net_inflow_4h = abs(oi_change_4h) * direction_factor / 1000000 - - if oi_change_24h != 0: - price_change_24h_val = futures_data.get('price_change_percent_24h', 0) - direction_factor = 1 if price_change_24h_val > 0 else -1 - net_inflow_24h = abs(oi_change_24h) * direction_factor / 1000000 - - # 方法3: 最后降级方案 - 使用资金费率和持仓量估算 - if abs(net_inflow_1h) < 0.1: - net_inflow_1h = funding_rate * oi_usd / 8 / 1000000 - if abs(net_inflow_4h) < 0.1: - net_inflow_4h = funding_rate * oi_usd / 2 / 1000000 - if abs(net_inflow_24h) < 0.1: - net_inflow_24h = funding_rate * oi_usd * 3 / 1000000 - - # 重新计算资金流向强度和趋势 - 基于实际净流入数据 - net_flow_abs_24h = abs(net_inflow_24h) - - # 计算资金流向强度 (基于24小时净流入绝对值) - if net_flow_abs_24h > 100: # 大于亿1美元 - capital_intensity = "强" - elif net_flow_abs_24h > 10: # 大于1000万美元 - capital_intensity = "中" - else: - capital_intensity = "弱" - - # 计算资金流向趋势 (基于24小时净流入方向和规模) - if net_inflow_24h > 100: - capital_trend = "大幅流入" - capital_flow = "大量流入" - elif net_inflow_24h > 10: - capital_trend = "稳定流入" - capital_flow = "流入" - elif net_inflow_24h < -100: - capital_trend = "大幅流出" - capital_flow = "大量流出" - elif net_inflow_24h < -10: - capital_trend = "稳定流出" - capital_flow = "流出" - else: - capital_trend = "平衡" - capital_flow = "平衡" - - # 计算买卖力量 - 使用实际成交量数据 - total_vol = long_vol_24h + short_vol_24h - - if total_vol > 0: - buy_power = (long_vol_24h / total_vol) * 100 - sell_power = (short_vol_24h / total_vol) * 100 - else: - buy_power = 50 - sell_power = 50 - - # 基差溢价计算 - if spot_data: - spot_price = spot_data.get('current_price', price) - basis_premium = ((price - spot_price) / spot_price) * 100 if spot_price > 0 else 0 - else: - basis_premium = 0 - - indicators = { - 'fear_greed_index': round(fear_greed, 1), - 'market_activity': round(market_activity, 1), - 'risk_score': round(risk_score, 1), - 'volatility': round(volatility, 2), - 'momentum': round(momentum, 2), - 'position_efficiency': round(position_efficiency, 1), - 'main_tendency': main_tendency, - 'capital_flow': capital_flow, - 'capital_intensity': capital_intensity, - 'capital_trend': capital_trend, - 'net_inflow_1h': round(net_inflow_1h, 2), - 'net_inflow_4h': round(net_inflow_4h, 2), - 'net_inflow_24h': round(net_inflow_24h, 1), - 'buy_power': round(buy_power, 1), - 'sell_power': round(sell_power, 1), - 'basis_premium': round(basis_premium, 3), - 'funding_rate_8h': round(funding_rate * 3, 4), # 8小时费率 - 'oi_change_5h': round(futures_data.get('open_interest_change_percent_4h', 0), 2), - 'ls_ratio_avg': round((ls_ratio_1h + ls_ratio_4h + ls_ratio_24h) / 3, 3), - 'liq_ratio': round((long_liq_24h / (long_liq_24h + short_liq_24h)) * 100, 1) if (long_liq_24h + short_liq_24h) > 0 else 50 - } - - except Exception as e: - logger.error(f"计算衍生指标失败: {e}") - # 返回默认值 - indicators = { - 'fear_greed_index': 50.0, - 'market_activity': 50.0, - 'risk_score': 50.0, - 'volatility': 2.0, - 'momentum': 0.0, - 'position_efficiency': 50.0, - 'main_tendency': "均衡", - 'capital_flow': "平衡", - 'capital_intensity': "弱", - 'capital_trend': "平衡", - 'net_inflow_1h': 0.0, - 'net_inflow_4h': 0.0, - 'net_inflow_24h': 0.0, - 'buy_power': 50.0, - 'sell_power': 50.0, - 'basis_premium': 0.0, - 'funding_rate_8h': 0.0, - 'oi_change_5h': 0.0, - 'ls_ratio_avg': 1.0, - 'liq_ratio': 50.0 - } - - return indicators - - def format_funding_rate_signal(self, symbol: str, alert_value: float) -> str: - """格式化资金费率信号(狙击信号)""" - futures_data, spot_data = self.get_coin_data(symbol) - - if not futures_data: - # 静默处理,不向用户显示错误消息 - logger.warning(f"📊 未找到 {symbol} 的数据,跳过信号生成") - return None # 返回None而不是错误消息 - - # 检查1H爆仓金额条件:必须大于等于5000 - liquidation_1h = futures_data.get('liquidation_usd_1h', 0) - if liquidation_1h < 5000: - logger.debug(f"📊 {symbol} 1H爆仓金额 ${liquidation_1h:,.0f} 低于5000门槛,跳过信号生成") - return None - - # 获取币安数据 - binance_data = self.get_binance_data(symbol) - - # 计算衍生指标 - indicators = self.calculate_derived_indicators(futures_data, spot_data, binance_data) - - # 获取当前时间 - 修改为精确到分钟 - current_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M") - - # 使用币安数据或CoinGlass数据 - 将24h改为4h - current_price = binance_data.get('mark_price', 0) or futures_data.get('current_price', 0) - price_change_4h = binance_data.get('price_4h_change', 0) or futures_data.get('price_change_percent_4h', 0) - market_cap = futures_data.get('market_cap_usd', 0) - volume_4h = binance_data.get('volume_4h', 0) or futures_data.get('volume_change_usd_4h', 0) - funding_rate = binance_data.get('funding_rate', 0) or futures_data.get('avg_funding_rate_by_oi', 0) - funding_rate_percent = funding_rate * 100 # 转换为百分比 - - # 获取今日播报次数并更新计数 - try: - from main import daily_signal_counter - # 增加计数并获取新的计数值 - daily_count = daily_signal_counter.increment_count(symbol) - except Exception: - daily_count = 1 - - # 构建信号消息 - 严格按照用户示例格式 - message = f"""🎯 {symbol} / USDT 狙击信号 (今日第{daily_count}次) - -⏰ 时间: {current_time} -🏷 价格: ${current_price:.4f} -📊 4H涨跌: {price_change_4h:.2f}% -🔥 4H交易额: ${self._format_amount_with_unit(volume_4h/1000000)} -💎 市值: ${self._format_amount_with_unit(market_cap/1000000)} -💰 资金费率: {funding_rate_percent:.6f}% - -💥 爆仓详情 -├ 4H总爆仓: ${futures_data.get('liquidation_usd_4h', 0):,.0f} -└ 多头: ${futures_data.get('long_liquidation_usd_4h', 0):,.0f} / 空头: ${futures_data.get('short_liquidation_usd_4h', 0):,.0f} - -💵 资金流向 -├ 方向 / 强度: {indicators['capital_flow']} / {indicators['capital_intensity']} -├ 1H / 4H净流入: {'+' if indicators['net_inflow_1h'] >= 0 else ''}${indicators['net_inflow_1h']:.2f}M / {'+' if indicators['net_inflow_4h'] >= 0 else ''}${indicators['net_inflow_4h']:.2f}M -├ 主力 / 多空均衡: {indicators['main_tendency']} / {indicators['ls_ratio_avg']:.3f} -└ 买卖力量: 多{indicators['buy_power']:.1f}% / 空{indicators['sell_power']:.1f}% - -📊 AI分析 -├ 短期波动 / 动量: {indicators['volatility']:.2f}% / {indicators['momentum']:.2f}% -├ 波动 / 趋势强度: {'高' if indicators['volatility'] > 3 else '中' if indicators['volatility'] > 1 else '低'} / {'强' if abs(indicators['momentum']) > 2 else '弱'} -├ 持仓成交比 / 效率: {futures_data.get('open_interest_volume_ratio', 0):.3f} / {indicators['position_efficiency']:.1f}% -├ 资金利用 / 参与度: {'高' if indicators['position_efficiency'] > 70 else '中' if indicators['position_efficiency'] > 30 else '低'} / {'活跃' if indicators['position_efficiency'] > 50 else '一般'} -├ 指数 / 情绪: {indicators['fear_greed_index']:.1f} / {'极度贪婪' if indicators['fear_greed_index'] > 80 else '贪婪' if indicators['fear_greed_index'] > 60 else '中性' if indicators['fear_greed_index'] > 40 else '恐惧' if indicators['fear_greed_index'] > 20 else '极度恐惧'} -└ 活跃度 / 风险分: {indicators['market_activity']:.1f}% / {indicators['risk_score']:.1f}% - -⚠️ 风险提示: 合约交易风险高,请谨慎操作。""" - - return message - - def format_open_interest_signal(self, symbol: str, alert_value: float) -> str: - """格式化持仓量信号(趋势信号)""" - futures_data, spot_data = self.get_coin_data(symbol) - - if not futures_data: - # 静默处理,不向用户显示错误消息 - logger.warning(f"📊 未找到 {symbol} 的数据,跳过信号生成") - return None # 返回None而不是错误消息 - - # 检查1H爆仓金额条件:必须大于等于5000 - liquidation_1h = futures_data.get('liquidation_usd_1h', 0) - if liquidation_1h < 5000: - logger.debug(f"📊 {symbol} 1H爆仓金额 ${liquidation_1h:,.0f} 低于5000门槛,跳过信号生成") - return None - - # 获取币安数据 - binance_data = self.get_binance_data(symbol) - - # 计算衍生指标 - indicators = self.calculate_derived_indicators(futures_data, spot_data, binance_data) - - # 获取当前时间 - 修改为精确到分钟 - current_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M") - - # 使用币安数据或CoinGlass数据 - 将24h改为4h - current_price = binance_data.get('mark_price', 0) or futures_data.get('current_price', 0) - price_change_4h = binance_data.get('price_4h_change', 0) or futures_data.get('price_change_percent_4h', 0) - market_cap = futures_data.get('market_cap_usd', 0) - volume_4h = binance_data.get('volume_4h', 0) or futures_data.get('volume_change_usd_4h', 0) - funding_rate = binance_data.get('funding_rate', 0) or futures_data.get('avg_funding_rate_by_oi', 0) - funding_rate_percent = funding_rate * 100 # 转换为百分比 - - # 获取今日播报次数并更新计数 - try: - from main import daily_signal_counter - # 增加计数并获取新的计数值 - daily_count = daily_signal_counter.increment_count(symbol) - except Exception: - daily_count = 1 - - # 构建信号消息 - 严格按照用户示例格式 - message = f"""🎯 {symbol} / USDT 趋势信号 (今日第{daily_count}次) - -⏰ 时间: {current_time} -🏷 价格: ${current_price:.4f} -📊 4H涨跌: {price_change_4h:.2f}% -🔥 4H交易额: ${self._format_amount_with_unit(volume_4h/1000000)} -💎 市值: ${self._format_amount_with_unit(market_cap/1000000)} -💰 资金费率: {funding_rate_percent:.6f}% - -💥 爆仓详情 -├ 4H总爆仓: ${futures_data.get('liquidation_usd_4h', 0):,.0f} -└ 多头: ${futures_data.get('long_liquidation_usd_4h', 0):,.0f} / 空头: ${futures_data.get('short_liquidation_usd_4h', 0):,.0f} - -💵 资金流向 -├ 方向 / 强度: {indicators['capital_flow']} / {indicators['capital_intensity']} -├ 1H / 4H净流入: {'+' if indicators['net_inflow_1h'] >= 0 else ''}${indicators['net_inflow_1h']:.2f}M / {'+' if indicators['net_inflow_4h'] >= 0 else ''}${indicators['net_inflow_4h']:.2f}M -├ 主力 / 多空均衡: {indicators['main_tendency']} / {indicators['ls_ratio_avg']:.3f} -└ 买卖力量: 多{indicators['buy_power']:.1f}% / 空{indicators['sell_power']:.1f}% - -📊 AI分析 -├ 短期波动 / 动量: {indicators['volatility']:.2f}% / {indicators['momentum']:.2f}% -├ 波动 / 趋势强度: {'高' if indicators['volatility'] > 3 else '中' if indicators['volatility'] > 1 else '低'} / {'强' if abs(indicators['momentum']) > 2 else '弱'} -├ 持仓成交比 / 效率: {futures_data.get('open_interest_volume_ratio', 0):.3f} / {indicators['position_efficiency']:.1f}% -├ 资金利用 / 参与度: {'高' if indicators['position_efficiency'] > 70 else '中' if indicators['position_efficiency'] > 30 else '低'} / {'活跃' if indicators['position_efficiency'] > 50 else '一般'} -├ 指数 / 情绪: {indicators['fear_greed_index']:.1f} / {'极度贪婪' if indicators['fear_greed_index'] > 80 else '贪婪' if indicators['fear_greed_index'] > 60 else '中性' if indicators['fear_greed_index'] > 40 else '恐惧' if indicators['fear_greed_index'] > 20 else '极度恐惧'} -└ 活跃度 / 风险分: {indicators['market_activity']:.1f}% / {indicators['risk_score']:.1f}% - -⚠️ 风险提示: 合约交易风险高,请谨慎操作。""" - - return message - - def format_rsi_signal(self, symbol: str, alert_value: float) -> str: - """格式化RSI信号(情绪信号)""" - futures_data, spot_data = self.get_coin_data(symbol) - - if not futures_data: - # 静默处理,不向用户显示错误消息 - logger.warning(f"📊 未找到 {symbol} 的数据,跳过信号生成") - return None # 返回None而不是错误消息 - - # 检查1H爆仓金额条件:必须大于等于5000 - liquidation_1h = futures_data.get('liquidation_usd_1h', 0) - if liquidation_1h < 5000: - logger.debug(f"📊 {symbol} 1H爆仓金额 ${liquidation_1h:,.0f} 低于5000门槛,跳过信号生成") - return None - - # 获取币安数据 - binance_data = self.get_binance_data(symbol) - - # 计算衍生指标 - indicators = self.calculate_derived_indicators(futures_data, spot_data, binance_data) - - # 获取当前时间 - 修改为精确到分钟 - current_time = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M") - - # 使用币安数据或CoinGlass数据 - 将24h改为4h - current_price = binance_data.get('mark_price', 0) or futures_data.get('current_price', 0) - price_change_4h = binance_data.get('price_4h_change', 0) or futures_data.get('price_change_percent_4h', 0) - market_cap = futures_data.get('market_cap_usd', 0) - volume_4h = binance_data.get('volume_4h', 0) or futures_data.get('volume_change_usd_4h', 0) - funding_rate = binance_data.get('funding_rate', 0) or futures_data.get('avg_funding_rate_by_oi', 0) - funding_rate_percent = funding_rate * 100 # 转换为百分比 - - # 获取今日播报次数并更新计数 - try: - from main import daily_signal_counter - # 增加计数并获取新的计数值 - daily_count = daily_signal_counter.increment_count(symbol) - except Exception: - daily_count = 1 - - # 构建信号消息 - 严格按照用户示例格式 - message = f"""🎯 {symbol} / USDT 情绪信号 (今日第{daily_count}次) - -⏰ 时间: {current_time} -🏷 价格: ${current_price:.4f} -📊 4H涨跌: {price_change_4h:.2f}% -🔥 4H交易额: ${self._format_amount_with_unit(volume_4h/1000000)} -💎 市值: ${self._format_amount_with_unit(market_cap/1000000)} -💰 资金费率: {funding_rate_percent:.6f}% - -💥 爆仓详情 -├ 4H总爆仓: ${futures_data.get('liquidation_usd_4h', 0):,.0f} -└ 多头: ${futures_data.get('long_liquidation_usd_4h', 0):,.0f} / 空头: ${futures_data.get('short_liquidation_usd_4h', 0):,.0f} - -💵 资金流向 -├ 方向 / 强度: {indicators['capital_flow']} / {indicators['capital_intensity']} -├ 1H / 4H净流入: {'+' if indicators['net_inflow_1h'] >= 0 else ''}${indicators['net_inflow_1h']:.2f}M / {'+' if indicators['net_inflow_4h'] >= 0 else ''}${indicators['net_inflow_4h']:.2f}M -├ 主力 / 多空均衡: {indicators['main_tendency']} / {indicators['ls_ratio_avg']:.3f} -└ 买卖力量: 多{indicators['buy_power']:.1f}% / 空{indicators['sell_power']:.1f}% - -📊 AI分析 -├ 短期波动 / 动量: {indicators['volatility']:.2f}% / {indicators['momentum']:.2f}% -├ 波动 / 趋势强度: {'高' if indicators['volatility'] > 3 else '中' if indicators['volatility'] > 1 else '低'} / {'强' if abs(indicators['momentum']) > 2 else '弱'} -├ 持仓成交比 / 效率: {futures_data.get('open_interest_volume_ratio', 0):.3f} / {indicators['position_efficiency']:.1f}% -├ 资金利用 / 参与度: {'高' if indicators['position_efficiency'] > 70 else '中' if indicators['position_efficiency'] > 30 else '低'} / {'活跃' if indicators['position_efficiency'] > 50 else '一般'} -├ 指数 / 情绪: {indicators['fear_greed_index']:.1f} / {'极度贪婪' if indicators['fear_greed_index'] > 80 else '贪婪' if indicators['fear_greed_index'] > 60 else '中性' if indicators['fear_greed_index'] > 40 else '恐惧' if indicators['fear_greed_index'] > 20 else '极度恐惧'} -└ 活跃度 / 风险分: {indicators['market_activity']:.1f}% / {indicators['risk_score']:.1f}% - -⚠️ 风险提示: 合约交易风险高,请谨慎操作。""" - - return message - - def format_signal(self, signal_type: str, symbol: str, alert_value: float) -> str: - """格式化信号消息""" - if signal_type == "funding_rate": - result = self.format_funding_rate_signal(symbol, alert_value) - elif signal_type == "open_interest": - result = self.format_open_interest_signal(symbol, alert_value) - elif signal_type == "rsi": - result = self.format_rsi_signal(symbol, alert_value) - elif signal_type in {"realtime_ut", "realtime_supertrend", "realtime_alphatrend"}: - # 兼容旧接口:alert_value 仅占位;真实信号应通过 format_realtime_trade_signal 传入完整字典 - logger.warning("实时买卖信号需要传入完整字典,当前仅返回占位文本") - result = "⚡ 实时买卖信号:请传入包含币种/周期/最新价格/动作/强度/时间/信号/触发原因的字典" - else: - return f"❌ 未知的信号类型: {signal_type}" - - # 如果结果为None,表示数据不可用,返回None而不是错误消息 - return result - - # --- TradingView 实时买卖信号扩展 --------------------------------- - @staticmethod - def _校验实时信号字段(signal: Dict[str, Any]) -> Optional[str]: - 必需字段 = ["币种", "周期", "最新价格", "动作", "强度", "时间", "信号", "触发原因"] - 缺失 = [f for f in 必需字段 if f not in signal] - return None if not 缺失 else "缺少字段: " + ", ".join(缺失) - - def format_realtime_trade_signal(self, signal: Dict[str, Any]) -> Optional[str]: - """ - 将 TradingView 系列实时买卖信号(UT Bot / Supertrend / AlphaTrend 等)格式化为 Telegram 文本 - 期望字段(中文):币种, 周期, 最新价格, 动作(买入/卖出), 强度, 时间(ISO 或已格式化), 信号, 触发原因 - 返回 None 表示字段不足,调用方可选择跳过 - """ - 校验 = self._校验实时信号字段(signal) - if 校验: - logger.warning("实时买卖信号字段不完整: %s", 校验) - return None - - # 处理时间格式,统一为北京时间显示 - try: - 原始时间 = signal.get("时间") - if isinstance(原始时间, datetime): - 北京时间值 = 原始时间.astimezone(timezone(timedelta(hours=8))) - else: - 北京时间值 = datetime.fromisoformat(str(原始时间)).astimezone(timezone(timedelta(hours=8))) - 时间文本 = 北京时间值.strftime("%Y-%m-%d %H:%M:%S") - except Exception: - 时间文本 = str(signal.get("时间")) - - 币种 = signal["币种"] - 周期 = signal["周期"] - 价格 = signal.get("最新价格", 0) - 动作 = signal.get("动作", "") - 强度 = signal.get("强度", 0) - 名称 = signal.get("信号", "实时信号") - 原因 = signal.get("触发原因", "") - - 消息 = f"""⚡ {名称} 实时信号 - -⏰ 时间:{时间文本} -🎯 标的:{币种} / {周期} -💰 最新价:{价格:.6f} -🔔 动作:{动作} 强度:{强度:.4f} -🧭 触发:{原因} - -提示:信号基于收盘数据计算,合约交易需自负盈亏。""" - - return 消息 - - def format_realtime_trade_signals(self, signals: Iterable[Dict[str, Any]]) -> List[str]: - """批量格式化实时买卖信号,过滤字段不完整的记录""" - 结果: List[str] = [] - for sig in signals: - 文本 = self.format_realtime_trade_signal(sig) - if 文本: - 结果.append(文本) - return 结果 - - def get_available_symbols(self) -> List[str]: - """获取可用的币种列表""" - symbols = set() - - # 从合约数据中获取 - for coin in self.futures_data: - if coin.get('symbol'): - symbols.add(coin['symbol']) - - # 从现货数据中获取 - for coin in self.spot_data: - if coin.get('symbol'): - symbols.add(coin['symbol']) - - return sorted(list(symbols)) - - def refresh_data(self): - """刷新数据""" - self.load_data() diff --git a/services/telegram-service/src/cards/data_provider.py b/services/telegram-service/src/cards/data_provider.py index 05838570..f29c0a09 100644 --- a/services/telegram-service/src/cards/data_provider.py +++ b/services/telegram-service/src/cards/data_provider.py @@ -41,6 +41,17 @@ def _get_allowed_symbols() -> Optional[Set[str]]: return _ALLOWED_SYMBOLS +def reset_symbols_cache(): + """ + 重置币种缓存,下次调用 _get_allowed_symbols() 时会重新加载。 + 用于热更新:修改 SYMBOLS_GROUPS 等配置后调用此函数。 + """ + global _ALLOWED_SYMBOLS, _SYMBOLS_LOADED + _ALLOWED_SYMBOLS = None + _SYMBOLS_LOADED = False + LOGGER.info("币种缓存已重置,下次请求将重新加载") + + def _parse_timestamp(ts_str: str) -> datetime: """解析时间戳字符串为 datetime,支持多种格式(统一为无时区)""" if not ts_str: diff --git a/services/trading-service/scripts/start.sh b/services/trading-service/scripts/start.sh index 5c25b8fa..3274cec0 100755 --- a/services/trading-service/scripts/start.sh +++ b/services/trading-service/scripts/start.sh @@ -26,9 +26,13 @@ safe_load_env() { if [[ "$file" == *"config/.env" ]] && [[ ! "$file" == *".example" ]]; then local perm=$(stat -c %a "$file" 2>/dev/null) if [[ "$perm" != "600" && "$perm" != "400" ]]; then - echo "❌ 错误: $file 权限为 $perm,必须设为 600" - echo " 执行: chmod 600 $file" - exit 1 + if [[ "${CODESPACES:-}" == "true" ]]; then + echo "⚠️ Codespace 环境,跳过权限检查 ($file: $perm)" + else + echo "❌ 错误: $file 权限为 $perm,必须设为 600" + echo " 执行: chmod 600 $file" + exit 1 + fi fi fi