Initial commit: 首次建仓,建立目录结构

This commit is contained in:
FXY
2026-06-11 23:49:54 +08:00
commit 4038a476b5
9396 changed files with 2372905 additions and 0 deletions

23
README.md Normal file
View File

@ -0,0 +1,23 @@
# Beast Trader
> ETH/USDT 永续合约 — 中低频价格行为策略研发
## 仓库结构
| 目录 | 说明 |
|:---|:---|
| `strategies/` | 策略代码(按版本归档) |
| `backtests/` | 回测结果 |
| `trades/` | 实盘交易记录(自动生成) |
| `daily_briefs/` | 每日简报(自动生成) |
| `docs/` | 文档(手册、报告) |
| `config/` | 配置文件 |
| `dashboard/` | Beast Trader Dashboard |
| `scripts/` | 自动化脚本 |
| `tools/` | 分析工具 |
## 工作流
编写 → 回测 → 评估 → 发布(tag) → 实盘 → 复盘
详见 `docs/guides/workflow_reference.md`

View File

@ -0,0 +1 @@
{"StructureFlowScalp":{"run_id":"5d9dbcad66211a164ff6ea751da905abf504163e","backtest_start_time":1781075231,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1778803200,"backtest_end_ts":1781049600}}

View File

@ -0,0 +1 @@
{"StructureFlowScalp":{"run_id":"0668e50d1dd7394da760d48c358fb89d9940b1a1","backtest_start_time":1781075580,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1778803200,"backtest_end_ts":1781049600}}

View File

@ -0,0 +1 @@
{"StructureFlowScalp":{"run_id":"dfb6675634b8f42b210664b4c725945953d69c38","backtest_start_time":1781075697,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1778803200,"backtest_end_ts":1781049600}}

View File

@ -0,0 +1 @@
{"StructureFlowScalp":{"run_id":"7e1a878d1791bcd1831a1606377d8f7f1f9c0b11","backtest_start_time":1781076774,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1778803200,"backtest_end_ts":1781049600}}

View File

@ -0,0 +1 @@
{"StructureFlowScalp":{"run_id":"7c8a6f0cafe3a24cd12498907194a9f13dfc9f0a","backtest_start_time":1781077325,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1778803200,"backtest_end_ts":1781049600}}

View File

@ -0,0 +1 @@
{"StructureFlowScalp":{"run_id":"611c4e0a697035bd2fe70b7fb5de30c17f4348ce","backtest_start_time":1781078709,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1778803200,"backtest_end_ts":1781049600}}

View File

@ -0,0 +1 @@
{"StructureFlowScalp":{"run_id":"ec43a348e14d2e8a0fdd7185d44a06c81c268a20","backtest_start_time":1781078822,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1778803200,"backtest_end_ts":1781049600}}

View File

@ -0,0 +1 @@
{"StructureFlowScalp":{"run_id":"40f3de7a7034b27ed88379d85c9004a6bdeda26a","backtest_start_time":1781078915,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1778803200,"backtest_end_ts":1781049600}}

View File

@ -0,0 +1 @@
{"StructureFlowScalp":{"run_id":"5006df21d4680b5858c830464277152b62693f2d","backtest_start_time":1781079411,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1778803200,"backtest_end_ts":1781049600}}

View File

@ -0,0 +1 @@
{"StructureFlowScalp":{"run_id":"8c9173b91d5557afcc9b0a2f22579422a79d5433","backtest_start_time":1781079721,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1672531200,"backtest_end_ts":1704067200}}

View File

@ -0,0 +1 @@
{"StructureFlowMomentumScalp":{"run_id":"55e90e69bb662d449103b9620f51e93c6346fe80","backtest_start_time":1781080191,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1672531200,"backtest_end_ts":1704067200}}

View File

@ -0,0 +1 @@
{"StructureFlowMomentumScalp":{"run_id":"961e38d12b56872445e5f4f32b6a9db3cf560e6f","backtest_start_time":1781080317,"timeframe":"5m","timeframe_detail":null,"backtest_start_ts":1672531200,"backtest_end_ts":1704067200}}

View File

@ -0,0 +1 @@
{"StructureFlowSwingV30":{"run_id":"3f57b6fb50a476c00713b62bd67ab20bd90c4910","backtest_start_time":1781095529,"timeframe":"4h","timeframe_detail":null,"backtest_start_ts":1672531200,"backtest_end_ts":1703980800}}

View File

@ -0,0 +1 @@
{"StructureFlowSwingV31":{"run_id":"014572729a6218a457183a82370acffdd8e774c5","backtest_start_time":1781096292,"timeframe":"4h","timeframe_detail":null,"backtest_start_ts":1672531200,"backtest_end_ts":1703980800}}

View File

@ -0,0 +1 @@
{"StructureFlowSwingV31":{"run_id":"7dc34d1e93588cd38be7826605224908c275ae4f","backtest_start_time":1781097736,"timeframe":"4h","timeframe_detail":null,"backtest_start_ts":1735689600,"backtest_end_ts":1767139200}}

View File

@ -0,0 +1 @@
{"StructureFlowSwingV31":{"run_id":"294bccacc1613b0ef19c3bb6e9542436c02d857d","backtest_start_time":1781097759,"timeframe":"4h","timeframe_detail":null,"backtest_start_ts":1751328000,"backtest_end_ts":1756598400}}

View File

@ -0,0 +1 @@
{"StructureFlowSwingV32":{"run_id":"f84aa424a0c90ffb223bb6fc5e38f9a10f67db9a","backtest_start_time":1781099073,"timeframe":"4h","timeframe_detail":null,"backtest_start_ts":1672531200,"backtest_end_ts":1703980800}}

View File

@ -0,0 +1 @@
{"StructureFlowSwingV32":{"run_id":"4ec9b78473c02a5ceb38b37d87c539bf2312b18c","backtest_start_time":1781099114,"timeframe":"4h","timeframe_detail":null,"backtest_start_ts":1672531200,"backtest_end_ts":1703980800}}

View File

@ -0,0 +1 @@
{"StructureFlowSwingV32":{"run_id":"c666105d00ba25635608f1fd7d581009b616535a","backtest_start_time":1781099393,"timeframe":"4h","timeframe_detail":null,"backtest_start_ts":1735689600,"backtest_end_ts":1767139200}}

View File

@ -0,0 +1 @@
{"StructureFlowSwingV32":{"run_id":"d2626584bb8172a85399c4dcb0972772a9f0621f","backtest_start_time":1781099393,"timeframe":"4h","timeframe_detail":null,"backtest_start_ts":1672531200,"backtest_end_ts":1703980800}}

View File

@ -0,0 +1 @@
{"StructureFlowStrategyV22c":{"run_id":"09c8c3017d2626514facb9f3d20d0b6601b8f00d","backtest_start_time":1781112343,"timeframe":"1h","timeframe_detail":null,"backtest_start_ts":1672531200,"backtest_end_ts":1767139200}}

View File

@ -0,0 +1 @@
{"StructureFlowStrategyV22d":{"run_id":"dadac4a0466e755cc789643df6326a91a1d44622","backtest_start_time":1781159624,"timeframe":"1h","timeframe_detail":null,"backtest_start_ts":1780963200,"backtest_end_ts":1781136000}}

View File

@ -0,0 +1 @@
{"StructureFlowStrategyV22d":{"run_id":"a486f604bb6528d007ee6459e8b6738e66aa582e","backtest_start_time":1781159650,"timeframe":"1h","timeframe_detail":null,"backtest_start_ts":1780963200,"backtest_end_ts":1781136000}}

View File

@ -0,0 +1 @@
{"StructureFlowStrategyV22d":{"run_id":"70aaa0ea25ba897e4b392d156ccfff724d968d5e","backtest_start_time":1781159710,"timeframe":"1h","timeframe_detail":null,"backtest_start_ts":1780963200,"backtest_end_ts":1781136000}}

View File

@ -0,0 +1,17 @@
---
services:
freqtrade:
image: freqtradeorg/freqtrade:2025.11
restart: unless-stopped
container_name: freqtrade
volumes:
- ./user_data:/freqtrade/user_data
ports:
- "127.0.0.1:8081:8080"
command: >
trade
--logfile /freqtrade/user_data/logs/freqtrade.log
--db-url sqlite:////freqtrade/user_data/tradesv3.sqlite
--config /freqtrade/user_data/config.json
--config /freqtrade/user_data/config.pairlist.json --config /freqtrade/user_data/secrets.json
--strategy StructureFlowStrategyV22d

View File

@ -0,0 +1,68 @@
{
"max_open_trades": 1,
"stake_currency": "USDT",
"stake_amount": "unlimited",
"tradable_balance_ratio": 0.99,
"fiat_display_currency": "USD",
"dry_run": true,
"dry_run_wallet": 1000,
"cancel_open_orders_on_exit": false,
"trading_mode": "futures",
"margin_mode": "isolated",
"entry_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1,
"price_last_balance": 0.0,
"check_depth_of_market": {
"enabled": false,
"bids_to_ask_delta": 1
}
},
"exit_pricing": {
"price_side": "same",
"use_order_book": true,
"order_book_top": 1
},
"unfilledtimeout": {
"entry": 10,
"exit": 10,
"exit_timeout_count": 0,
"unit": "minutes"
},
"exchange": {
"name": "binance",
"key": "",
"secret": "",
"ccxt_config": {},
"ccxt_async_config": {}
},
"pairlists": [
{
"method": "StaticPairList",
"allow_inactive": false
}
],
"telegram": {
"enabled": true,
"token": "8872284120:AAHZ9vbi_949uri-aZ4-BCIAqyDQ1OLuc7c",
"chat_id": "1436534523",
"notification_settings": {
"status": "on",
"warning": "on",
"startup": "on",
"entry": "on",
"entry_fill": "on",
"exit": "on",
"exit_fill": "on",
"exit_cancel": "on",
"protection_trigger": "on",
"protection_trigger_global": "on"
}
},
"bot_name": "freqtrade",
"initial_state": "running",
"internals": {
"process_throttle_secs": 5
}
}

View File

@ -0,0 +1,8 @@
{
"exchange": {
"name": "binance",
"pair_whitelist": [
"ETH/USDT:USDT"
]
}
}

View File

@ -0,0 +1,50 @@
╔══════════════════════════════════════════════════════════╗
║ 📊 Structure Flow Strategy v2.1 — 每日运行简报 ║
║ 📅 2026年06月08日 23:55 (北京时间) ║
╚══════════════════════════════════════════════════════════╝
┌─ 🟢 一、Bot 运行状态────────────────────────────────┐
│ ✅ 状态: 运行中
│ ✅ 重启次数: 0 (稳定运行)
│ ⏱️ 今日运行: 5小时52分钟
│ 💓 最后心跳: 2026-06-08 23:54:26 (35秒前)
│ 💓 今日心跳: 349 次
│ ✅ 错误: 0
│ 🔍 白名单更新: 8 次
│ 📋 交易对: ETH/USDT:USDT
└─────────────────────────────────────────────────────┘
┌─ 📡 二、入场筛选活动──────────────────────────────┐
│ 🔕 策略处于静默状态
│ 🎯 入场标准 (需同时满足):
│ ① D1趋势方向一致
│ ② 4H供需区位置正确
│ ③ K线形态确认 (Pin Bar / Engulfing)
│ ④ 止损距离在合理范围内
│ ⑤ 支撑/阻力存活 (近期被测试过)
│ ⑥ 4H趋势强度在扩张
│ ⑦ 冷却期内无重复信号
静默 = 上述条件未同时满足 → 策略在保护资金
└─────────────────────────────────────────────────────┘
┌─ 💰 三、交易活动──────────────────────────────────────┐
│ 📭 当前持仓: 无
│ 🆕 今日新开: 0 笔
│ 🔒 今日平仓: 0 笔
└─────────────────────────────────────────────────────┘
┌─ 🏥 五、策略健康度────────────────────────────────────┐
│ ✅ 心跳正常
│ ✅ 零错误
│ ✅ 稳定运行
│ 🟢 健康度: 优秀 (100/100)
└─────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────
📌 策略: StructureFlowStrategyV21 | 模式: dry_run | 杠杆: 1x
📌 服务器: 43.163.225.30 | 日志: /home/ubuntu/freqtrade/user_data/logs/freqtrade.log
📌 报告生成: 2026-06-08 23:55:01 (北京时间)
────────────────────────────────────────────────────────────

View File

@ -0,0 +1,52 @@
╔══════════════════════════════════════════════════════════╗
║ 📊 Structure Flow Strategy v2.1 — 每日运行简报 ║
║ 📅 2026年06月09日 23:55 (北京时间) ║
╚══════════════════════════════════════════════════════════╝
┌─ 🟢 一、Bot 运行状态────────────────────────────────┐
│ ✅ 状态: 运行中
│ ✅ 重启次数: 0 (稳定运行)
│ ⏱️ 今日运行: 15小时54分钟
│ 💓 最后心跳: 2026-06-09 23:54:11 (51秒前)
│ 💓 今日心跳: 953 次
│ ❌ 错误: 2 条
│ └ 2026-06-09 01:11:28,692 - telegram.ext.Updater - ERROR - Exception happened whil
│ └ 2026-06-09 01:11:30,023 - telegram.ext.Updater - ERROR - Exception happened whil
│ 🔍 白名单更新: 15 次
│ 📋 交易对: ETH/USDT:USDT
└─────────────────────────────────────────────────────┘
┌─ 📡 二、入场筛选活动──────────────────────────────┐
│ 🔕 策略处于静默状态
│ 🎯 入场标准 (需同时满足):
│ ① D1趋势方向一致
│ ② 4H供需区位置正确
│ ③ K线形态确认 (Pin Bar / Engulfing)
│ ④ 止损距离在合理范围内
│ ⑤ 支撑/阻力存活 (近期被测试过)
│ ⑥ 4H趋势强度在扩张
│ ⑦ 冷却期内无重复信号
静默 = 上述条件未同时满足 → 策略在保护资金
└─────────────────────────────────────────────────────┘
┌─ 💰 三、交易活动──────────────────────────────────────┐
│ 📭 当前持仓: 无
│ 🆕 今日新开: 0 笔
│ 🔒 今日平仓: 0 笔
└─────────────────────────────────────────────────────┘
┌─ 🏥 五、策略健康度────────────────────────────────────┐
│ ✅ 心跳正常
│ ⚡ 轻微错误: 2 条 (不影响运行)
│ ✅ 稳定运行
│ 🟢 健康度: 优秀 (100/100)
└─────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────
📌 策略: StructureFlowStrategyV21 | 模式: dry_run | 杠杆: 1x
📌 服务器: 43.163.225.30 | 日志: /home/ubuntu/freqtrade/user_data/logs/freqtrade.log
📌 报告生成: 2026-06-09 23:55:02 (北京时间)
────────────────────────────────────────────────────────────

View File

@ -0,0 +1,50 @@
╔══════════════════════════════════════════════════════════╗
║ 📊 Structure Flow Strategy v2.1 — 每日运行简报 ║
║ 📅 2026年06月10日 23:55 (北京时间) ║
╚══════════════════════════════════════════════════════════╝
┌─ 🟢 一、Bot 运行状态────────────────────────────────┐
│ ✅ 状态: 运行中
│ ✅ 重启次数: 0 (稳定运行)
│ ⏱️ 今日运行: 15小时54分钟
│ 💓 最后心跳: 2026-06-10 23:54:32 (29秒前)
│ 💓 今日心跳: 953 次
│ ✅ 错误: 0
│ 🔍 白名单更新: 16 次
│ 📋 交易对: ETH/USDT:USDT
└─────────────────────────────────────────────────────┘
┌─ 📡 二、入场筛选活动──────────────────────────────┐
│ 🔕 策略处于静默状态
│ 🎯 入场标准 (需同时满足):
│ ① D1趋势方向一致
│ ② 4H供需区位置正确
│ ③ K线形态确认 (Pin Bar / Engulfing)
│ ④ 止损距离在合理范围内
│ ⑤ 支撑/阻力存活 (近期被测试过)
│ ⑥ 4H趋势强度在扩张
│ ⑦ 冷却期内无重复信号
静默 = 上述条件未同时满足 → 策略在保护资金
└─────────────────────────────────────────────────────┘
┌─ 💰 三、交易活动──────────────────────────────────────┐
│ 📭 当前持仓: 无
│ 🆕 今日新开: 0 笔
│ 🔒 今日平仓: 0 笔
└─────────────────────────────────────────────────────┘
┌─ 🏥 五、策略健康度────────────────────────────────────┐
│ ✅ 心跳正常
│ ✅ 零错误
│ ✅ 稳定运行
│ 🟢 健康度: 优秀 (100/100)
└─────────────────────────────────────────────────────┘
────────────────────────────────────────────────────────────
📌 策略: StructureFlowStrategyV21 | 模式: dry_run | 杠杆: 1x
📌 服务器: 43.163.225.30 | 日志: /home/ubuntu/freqtrade/user_data/logs/freqtrade.log
📌 报告生成: 2026-06-10 23:55:01 (北京时间)
────────────────────────────────────────────────────────────

Binary file not shown.

Binary file not shown.

834
dashboard/index.html Normal file
View File

@ -0,0 +1,834 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">
<meta name="theme-color" content="#0d1117">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="manifest.json">
<title>Beast Trader</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
:root{
--bg:#0d1117;--surface:#161b22;--surface2:#21262d;
--border:#30363d;--text:#e6edf3;--text2:#8b949e;--text3:#6e7681;
--green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#58a6ff;
}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);font-size:14px;padding-bottom:70px;overflow-x:hidden}
.header{padding:16px 16px 8px;display:flex;justify-content:space-between;align-items:center;border-bottom:0.5px solid var(--border);background:var(--surface)}
.header h1{font-size:18px;font-weight:500}
.header .sub{font-size:12px;color:var(--text2)}
.st-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
.st-ok{background:var(--green)}.st-warn{background:var(--yellow)}.st-err{background:var(--red)}
.tabs{display:flex;background:var(--surface);border-bottom:0.5px solid var(--border);position:sticky;top:0;z-index:10}
.tab{flex:1;text-align:center;padding:12px;font-size:13px;color:var(--text2);cursor:pointer;border-bottom:2px solid transparent}
.tab.active{color:var(--blue);border-bottom-color:var(--blue)}
.tc{display:none;padding:12px 16px}.tc.active{display:block}
.mg{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}
.mc{background:var(--surface);border-radius:10px;padding:12px;border:0.5px solid var(--border)}
.mc .l{font-size:11px;color:var(--text3);margin-bottom:4px}
.mc .v{font-size:18px;font-weight:500}.v.gn{color:var(--green)}.v.rd{color:var(--red)}.v.bl{color:var(--blue)}.v.yl{color:var(--yellow)}
.ca{background:var(--surface);border-radius:10px;padding:12px;margin-bottom:12px;border:0.5px solid var(--border);overflow:hidden}
.ca .ct{font-size:13px;font-weight:500;margin-bottom:8px;display:flex;justify-content:space-between}
svg{width:100%;height:auto}
.sr-badge{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:500}
.sr-support{background:rgba(63,185,80,0.15);color:var(--green)}
.sr-resist{background:rgba(248,81,73,0.15);color:var(--red)}
.sr-price{background:rgba(88,166,255,0.15);color:var(--blue)}
.sr-row{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
.fl{display:flex;flex-direction:column;gap:6px;margin-bottom:12px}
.fi{background:var(--surface);border-radius:8px;padding:10px 12px;border:0.5px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.fi .nm{font-size:13px;font-weight:500}.fi .ds{font-size:11px;color:var(--text3);margin-top:2px}
.fi .st{font-size:12px;font-weight:500;padding:3px 8px;border-radius:5px}
.st-p{background:rgba(63,185,80,0.2);color:var(--green)}
.st-f{background:rgba(248,81,73,0.2);color:var(--red)}
.cb{padding:12px;border-radius:10px;margin-bottom:12px;text-align:center;font-size:15px;font-weight:500}
.cb.rdy{background:rgba(63,185,80,0.15);color:var(--green);border:0.5px solid var(--green)}
.cb.wt{background:rgba(210,153,34,0.15);color:var(--yellow);border:0.5px solid var(--yellow)}
.cb.no{background:rgba(248,81,73,0.1);color:var(--text3);border:0.5px solid var(--border)}
.ftr{position:fixed;bottom:0;left:0;right:0;background:var(--surface);border-top:0.5px solid var(--border);padding:8px 16px;display:flex;justify-content:space-between;align-items:center;z-index:20}
.ftr .fi2{font-size:11px;color:var(--text3)}
.rf{background:var(--surface2);border:0.5px solid var(--border);color:var(--text);padding:6px 16px;border-radius:8px;font-size:13px;cursor:pointer}
.sym{display:flex;gap:6px;margin-bottom:12px}
.sb{flex:1;padding:8px;text-align:center;border-radius:8px;border:0.5px solid var(--border);font-size:13px;cursor:pointer;color:var(--text2);background:var(--surface)}
.sb.ac{border-color:var(--blue);color:var(--blue);background:rgba(88,166,255,0.1)}
.ta{font-size:16px}.ta.up{color:var(--green)}.ta.dn{color:var(--red)}.ta.nt{color:var(--text3)}
.leg{display:flex;gap:12px;font-size:11px;color:var(--text3);margin-bottom:4px}
.leg span{display:flex;align-items:center;gap:4px}
.dot{width:8px;height:8px;border-radius:50%;display:inline-block}.dot.gn{background:var(--green)}.dot.rd{background:var(--red)}
.ld{text-align:center;padding:30px;color:var(--text3);font-size:14px}
.er{text-align:center;padding:20px;color:var(--red);font-size:13px}
.sec-title{font-size:12px;font-weight:500;color:var(--text2);margin:8px 0 4px}
.tr{background:var(--surface);border-radius:8px;padding:10px 12px;margin-bottom:6px;border:0.5px solid var(--border);font-size:12px}
.tr .tp{display:flex;justify-content:space-between;align-items:center;margin-bottom:4px}
.tr .td{display:flex;gap:16px;color:var(--text3);font-size:11px}
.tr .pnl{font-weight:600;font-size:13px}
.tbl{width:100%;border-collapse:collapse;font-size:11px}
.tbl th{color:var(--text3);font-weight:400;text-align:left;padding:4px 8px;border-bottom:0.5px solid var(--border)}
.tbl td{padding:6px 8px;border-bottom:0.5px solid var(--border);color:var(--text2)}
.tbl .num{text-align:right}
.tbl .win{color:var(--green)}.tbl .lose{color:var(--red)}
.pos-badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:10px;font-weight:500}
.pos-long{background:rgba(63,185,80,0.2);color:var(--green)}
.pos-short{background:rgba(248,81,73,0.2);color:var(--red)}
.empty-state{text-align:center;padding:24px;color:var(--text3);font-size:13px}
.g3{grid-template-columns:1fr 1fr 1fr}
.g4{grid-template-columns:1fr 1fr 1fr 1fr}
</style>
</head>
<body>
<div class="header">
<div>
<h1>Beast Trader</h1>
<div class="sub">v2.2d · Dashboard V1.2 · 直连 Binance</div>
</div>
<div><span class="st-dot st-warn" id="stDot"></span><span style="font-size:12px;color:var(--text2)" id="stTxt">初始化</span></div>
</div>
<div id="csBanner" style="display:none;background:rgba(248,81,73,0.15);border:1px solid var(--red);border-radius:8px;padding:8px 12px;margin-bottom:8px;font-size:12px;color:var(--red);text-align:center">
⚠️ CloudStudio 不支持 HTTPS→HTTP 请求,请用手机浏览器打开<br>
<a href="http://43.163.225.30:9000/" style="color:var(--green);text-decoration:underline">http://43.163.225.30:9000/</a>
</div>
<div class="tabs">
<div class="tab active" onclick="swTab('st')">结构分析</div>
<div class="tab" onclick="swTab('diag')">信号诊断</div>
<div class="tab" onclick="swTab('trade')">交易监测</div>
</div>
<div class="tc active" id="tab-st">
<div class="sym">
<div class="sb ac" onclick="swSym('ETH/USDT')">ETH/USDT</div>
<div class="sb" onclick="swSym('BTC/USDT')">BTC/USDT</div>
</div>
<div class="mg" id="mGrid">
<div class="mc"><div class="l">当前价格</div><div class="v bl" id="cPrice">--</div></div>
<div class="mc"><div class="l">D1 趋势</div><div class="v" id="cTrend"><span class="ta nt"></span> <span>计算中</span></div></div>
<div class="mc"><div class="l">支撑</div><div class="v gn" id="cSup">--</div></div>
<div class="mc"><div class="l">阻力</div><div class="v rd" id="cRes">--</div></div>
</div>
<div class="sr-row" id="srRow"></div>
<div class="ca">
<div class="ct"><span id="chP">ETH/USDT</span><span style="color:var(--text3);font-size:11px">最近 48 根 1H K线</span></div>
<div class="leg"><span><span class="dot gn"></span>Swing High</span><span><span class="dot rd"></span>Swing Low</span></div>
<svg viewBox="0 0 340 160" id="chSvg"><rect width="340" height="160" fill="var(--surface)" rx="6"/><text x="170" y="80" text-anchor="middle" fill="var(--text3)" font-size="12">获取数据中...</text></svg>
</div>
<div class="ca">
<div class="ct"><span>条件检查</span><span style="color:var(--text3);font-size:11px">实时</span></div>
<div id="condCard"><div class="ld">加载中...</div></div>
</div>
</div>
<div class="tc" id="tab-diag">
<div style="margin-bottom:8px;font-size:13px;color:var(--text2)">ETH/USDT · 实时过滤条件检查</div>
<div id="conclBar"></div>
<div class="fl" id="fList"><div class="ld">加载中...</div></div>
</div>
<div class="tc" id="tab-trade">
<div class="mg g4" id="accGrid">
<div class="mc"><div class="l">当前余额</div><div class="v bl" id="accBalance">--</div></div>
<div class="mc"><div class="l">总收益</div><div class="v" id="accPnl">--</div></div>
<div class="mc"><div class="l">胜率</div><div class="v" id="accWinRate">--</div></div>
<div class="mc"><div class="l">交易数</div><div class="v" id="accTrades">--</div></div>
</div>
<div style="display:flex;gap:16px;margin-bottom:8px;font-size:11px;color:var(--text3)">
<span id="accStrategy">策略: --</span>
<span id="accUpdate">更新: --</span>
</div>
<div class="sec-title">📌 当前持仓</div>
<div id="openPos"><div class="ld">加载中...</div></div>
<div class="sec-title">📋 近期交易 (<span id="tradeTotal">0</span>笔)</div>
<div style="overflow-x:auto">
<table class="tbl"><thead>
<tr><th>时间</th><th>方向</th><th>入场价</th><th>出场价</th><th class="num">盈亏</th><th>原因</th></tr>
</thead><tbody id="tradeBody"><tr><td colspan="6" class="empty-state">暂无交易记录</td></tr></tbody></table>
</div>
<div class="sec-title" style="margin-top:12px">📊 每日盈亏 (近30天)</div>
<div class="ca">
<svg viewBox="0 0 340 120" id="pnlSvg"><rect width="340" height="120" fill="var(--surface)" rx="6"/><text x="170" y="60" text-anchor="middle" fill="var(--text3)" font-size="12">暂无数据</text></svg>
</div>
<div class="sec-title">📉 盈亏分布</div>
<div class="ca">
<svg viewBox="0 0 340 100" id="distSvg"><rect width="340" height="100" fill="var(--surface)" rx="6"/><text x="170" y="50" text-anchor="middle" fill="var(--text3)" font-size="12">暂无数据</text></svg>
</div>
<div class="sec-title" style="margin-top:12px">📊 回测年化表现2021-2026</div>
<div style="font-size:12px;color:var(--text2);margin-bottom:12px">
<table style="width:100%;border-collapse:collapse;font-size:12px">
<tr style="background:var(--surface2)">
<th style="padding:6px 8px;text-align:left;border-bottom:1px solid var(--border)">年份</th>
<th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--border)">交易数</th>
<th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--border)">收益率</th>
<th style="padding:6px 8px;text-align:right;border-bottom:1px solid var(--border)">终值</th>
</tr>
<tr><td style="padding:6px 8px">2021</td><td style="padding:6px 8px;text-align:right">172</td><td style="padding:6px 8px;text-align:right;color:var(--up)">+251.16%</td><td style="padding:6px 8px;text-align:right">$35,116</td></tr>
<tr style="background:var(--surface2)"><td style="padding:6px 8px">2022</td><td style="padding:6px 8px;text-align:right">204</td><td style="padding:6px 8px;text-align:right;color:var(--up)">+110.91%</td><td style="padding:6px 8px;text-align:right">$21,091</td></tr>
<tr><td style="padding:6px 8px">2023</td><td style="padding:6px 8px;text-align:right">182</td><td style="padding:6px 8px;text-align:right;color:var(--up)">+49.35%</td><td style="padding:6px 8px;text-align:right">$14,935</td></tr>
<tr style="background:var(--surface2)"><td style="padding:6px 8px">2024</td><td style="padding:6px 8px;text-align:right">232</td><td style="padding:6px 8px;text-align:right;color:var(--up)">+185.84%</td><td style="padding:6px 8px;text-align:right">$28,584</td></tr>
<tr><td style="padding:6px 8px">2025</td><td style="padding:6px 8px;text-align:right">221</td><td style="padding:6px 8px;text-align:right;color:var(--up)">+608.24%</td><td style="padding:6px 8px;text-align:right">$70,824</td></tr>
<tr style="background:var(--surface2)"><td style="padding:6px 8px">2026</td><td style="padding:6px 8px;text-align:right">54</td><td style="padding:6px 8px;text-align:right;color:var(--dn)">-11.87%</td><td style="padding:6px 8px;text-align:right">$8,813</td></tr>
</table>
<div style="margin-top:6px;padding:6px 8px;background:var(--surface2);border-radius:4px">
<b>v2.2d 全周期 (2021-2026) 总收益:+205,684%</b> · CAGR 309.01% · Sharpe 1.03 · 最大回撤20.58%
</div>
</div>
</div>
<div class="ftr">
<span class="fi2" id="ftInfo">上次更新: --</span>
<button class="rf" onclick="refresh()">刷新</button>
</div>
<script>
// ============================================================
// 指标计算(移植自 v2.2b 策略)
// ============================================================
function detectSwingPoints(high, low, window=5) {
const sh = new Array(high.length).fill(NaN);
const sl = new Array(low.length).fill(NaN);
for (let i = window; i < high.length - window; i++) {
const leftH = high.slice(i - window, i);
const rightH = high.slice(i + 1, i + window + 1);
if (high[i] > Math.max(...leftH) && high[i] > Math.max(...rightH)) sh[i] = high[i];
const leftL = low.slice(i - window, i);
const rightL = low.slice(i + 1, i + window + 1);
if (low[i] < Math.min(...leftL) && low[i] < Math.min(...rightL)) sl[i] = low[i];
}
return [sh, sl];
}
function buildStructure(high, low, close, sh, sl) {
const n = high.length;
const tu = new Array(n).fill(false);
const td = new Array(n).fill(false);
const sup = new Array(n).fill(NaN);
const res = new Array(n).fill(NaN);
const idm = new Array(n).fill(false);
const isp = new Array(n).fill(false);
const shP = [], slP = [];
for (let i = 0; i < n; i++) {
if (!isNaN(sh[i])) { shP.push(sh[i]); if (shP.length > 4) shP.shift(); }
if (!isNaN(sl[i])) { slP.push(sl[i]); if (slP.length > 4) slP.shift(); }
if (shP.length >= 2 && slP.length >= 2) {
if (shP[shP.length-1] > shP[shP.length-2] && slP[slP.length-1] > slP[slP.length-2]) tu[i] = true;
else if (shP[shP.length-1] < shP[shP.length-2] && slP[slP.length-1] < slP[slP.length-2]) td[i] = true;
else if (i > 0) { tu[i] = tu[i-1]; td[i] = td[i-1]; }
} else if (i > 0) { tu[i] = tu[i-1]; td[i] = td[i-1]; }
if (slP.length) sup[i] = slP[slP.length-1];
if (shP.length) res[i] = shP[shP.length-1];
const c = close[i];
if (!isNaN(sup[i]) && !isNaN(res[i]) && res[i] - sup[i] > 0) {
const pct = (c - sup[i]) / (res[i] - sup[i]);
idm[i] = pct < 0.35;
isp[i] = pct > 0.65;
}
}
return {tu, td, sup, res, idm, isp};
}
function trendStrength(high, low, sh, sl, minStr=-0.20) {
const n = high.length;
const up = new Array(n).fill(NaN), dn = new Array(n).fill(NaN);
const supS = new Array(n).fill(false), sdn = new Array(n).fill(false);
const shP = [], slP = [];
for (let i = 0; i < n; i++) {
if (!isNaN(sh[i])) { shP.push(sh[i]); if (shP.length > 4) shP.shift(); }
if (!isNaN(sl[i])) { slP.push(sl[i]); if (slP.length > 4) slP.shift(); }
if (shP.length >= 2 && slP.length >= 2) {
const hhD = (shP[shP.length-1] - shP[shP.length-2]) / shP[shP.length-2];
const hlD = (slP[slP.length-1] - slP[slP.length-2]) / slP[slP.length-2];
up[i] = hhD + hlD;
dn[i] = -(hhD + hlD);
}
}
for (let i = 0; i < n; i++) {
supS[i] = !isNaN(up[i]) && up[i] > minStr;
sdn[i] = !isNaN(dn[i]) && dn[i] > minStr;
}
return {up, dn, supS, sdn};
}
function supportAlive(low, close, support) {
const n = low.length;
const r = new Array(n).fill(false);
let found = false;
for (let i = 0; i < n; i++) {
if (isNaN(support[i])) { r[i] = found; continue; }
const touched = low[i] <= support[i] * 1.005 && low[i] >= support[i] * 0.995;
const held = close[i] > support[i];
if (touched && held) found = true;
r[i] = found;
if (i >= 3 && !touched) {
let keep = false;
for (let j = Math.max(0, i-2); j <= i; j++) {
const tj = low[j] <= support[j] * 1.005 && low[j] >= support[j] * 0.995;
const hj = close[j] > support[j];
if (tj && hj) keep = true;
}
r[i] = keep;
}
}
return r;
}
function resistAlive(high, close, resist) {
const n = high.length;
const r = new Array(n).fill(false);
for (let i = 0; i < n; i++) {
if (isNaN(resist[i])) { r[i] = i > 0 ? r[i-1] : false; continue; }
let found = false;
for (let j = Math.max(0, i-2); j <= i; j++) {
const touched = high[j] >= resist[j] * 0.995 && high[j] <= resist[j] * 1.005;
const held = close[j] < resist[j];
if (touched && held) found = true;
}
r[i] = found;
}
return r;
}
function computeAll(pair, df1h, df4h, df1d) {
const params = {swingD1:10, swingH4:8, pbr:0.6, msd:0.50, tsm:-0.20};
// D1
const [sh1d, sl1d] = detectSwingPoints(df1d.high, df1d.low, params.swingD1);
const st1d = buildStructure(df1d.high, df1d.low, df1d.close, sh1d, sl1d);
const li1d = st1d.tu.length-1;
const trUp1d = st1d.tu[li1d], trDn1d = st1d.td[li1d];
// 4H
const [sh4h, sl4h] = detectSwingPoints(df4h.high, df4h.low, params.swingH4);
const st4h = buildStructure(df4h.high, df4h.low, df4h.close, sh4h, sl4h);
const ts4h = trendStrength(df4h.high, df4h.low, sh4h, sl4h, params.tsm);
const li4h = st4h.tu.length-1;
const sup4h = st4h.sup[li4h], res4h = st4h.res[li4h];
const idx4h = st4h.idm[li4h], isp4h = st4h.isp[li4h];
const sa4h = supportAlive(df4h.low, df4h.close, st4h.sup)[li4h];
const ra4h = resistAlive(df4h.high, df4h.close, st4h.res)[li4h];
const sUp4h = ts4h.supS[li4h], sDn4h = ts4h.sdn[li4h];
const tsUp = ts4h.up[li4h], tsDn = ts4h.dn[li4h];
// 1H
const lh = df1h.close.length-1;
const cPrice = df1h.close[lh];
const bod = df1h.close.map((c,i) => Math.abs(c - df1h.open[i]));
const tr = df1h.high.map((h,i) => Math.max(h - df1h.low[i], 0.0001));
const uw = df1h.high.map((h,i) => h - (df1h.close[i] > df1h.open[i] ? df1h.close[i] : df1h.open[i]));
const lw = df1h.high.map((h,i) => (df1h.close[i] > df1h.open[i] ? df1h.open[i] : df1h.close[i]) - df1h.low[i]);
const isPin = tr.map((t,i) => (uw[i] + lw[i]) / t > params.pbr);
const bp = isPin.map((p,i) => p && df1h.close[i] > df1h.open[i] && lw[i] > uw[i]);
const bsh = isPin.map((p,i) => p && df1h.close[i] < df1h.open[i] && uw[i] > lw[i]);
const be = df1h.close.map((c,i) => i>0 && c > df1h.open[i-1] && df1h.open[i] < df1h.close[i-1] && c > df1h.open[i]);
const bse = df1h.close.map((c,i) => i>0 && c < df1h.open[i-1] && df1h.open[i] > df1h.close[i-1] && c < df1h.open[i]);
const buS = bp.map((p,i) => p || (be[i]||false));
const beS = bsh.map((p,i) => p || (bse[i]||false));
// Distances
const lsd = !isNaN(sup4h) && sup4h > 0 ? (cPrice - sup4h) / cPrice : null;
const ssd = !isNaN(res4h) && res4h > 0 ? (res4h - cPrice) / cPrice : null;
const ldOk = lsd !== null && lsd <= params.msd && lsd > 0.003;
const sdOk = ssd !== null && ssd <= params.msd && ssd > 0.003;
// Zone info
let zoneW = null, posPct = null;
if (!isNaN(sup4h) && !isNaN(res4h) && sup4h > 0 && res4h > sup4h) {
zoneW = ((res4h - sup4h) / sup4h) * 100;
posPct = ((cPrice - sup4h) / (res4h - sup4h)) * 100;
}
// Swing points history
const sHighs = [], sLows = [];
for (let i = 0; i < sh4h.length; i++) {
if (!isNaN(sh4h[i])) sHighs.push({price:sh4h[i], time:df4h.time[i]});
if (!isNaN(sl4h[i])) sLows.push({price:sl4h[i], time:df4h.time[i]});
}
// Diagnosis
const longOk = trUp1d && idx4h && ldOk && sa4h && sUp4h;
const shortOk = trDn1d && isp4h && sdOk && ra4h && sDn4h;
return {
cPrice: Math.round(cPrice * 100) / 100,
sup4h: !isNaN(sup4h) ? Math.round(sup4h * 100) / 100 : null,
res4h: !isNaN(res4h) ? Math.round(res4h * 100) / 100 : null,
zoneW: zoneW !== null ? Math.round(zoneW * 100) / 100 : null,
posPct: posPct !== null ? Math.round(posPct * 10) / 10 : null,
trUp1d: trUp1d, trDn1d: trDn1d,
idx4h, isp4h, sa4h, ra4h, sUp4h, sDn4h,
tsUp: !isNaN(tsUp) ? Math.round(tsUp * 10000) / 100 : null,
tsDn: !isNaN(tsDn) ? Math.round(tsDn * 10000) / 100 : null,
lsd: lsd !== null ? Math.round(lsd * 10000) / 100 : null,
ssd: ssd !== null ? Math.round(ssd * 10000) / 100 : null,
ldOk, sdOk, longOk, shortOk,
buS: buS[lh], beS: beS[lh],
priceHist: df1h.close,
sHighs: sHighs.slice(-6), sLows: sLows.slice(-6),
};
}
// ============================================================
// 数据获取 (通过服务器后端 API)
// ============================================================
let cache = {};
let curSym = 'ETH/USDT';
const IS_CLOUDSTUDIO = window.location.hostname.includes('codebuddy.work') || window.location.hostname.includes('app.codebuddy');
const SERVER_URL = IS_CLOUDSTUDIO ? 'http://43.163.225.30:9000' : window.location.origin;
const API_KEY = 'beast2025';
async function fetchFromServer() {
const headers = { 'X-API-Key': API_KEY, 'Accept': 'application/json' };
const [structRes, diagRes] = await Promise.all([
fetch(SERVER_URL + '/api/market-structure', { headers }),
fetch(SERVER_URL + '/api/signal-diagnosis', { headers }),
]);
if (!structRes.ok || !diagRes.ok) throw new Error('API ' + structRes.status);
const struct = await structRes.json();
const diag = await diagRes.json();
return { struct, diag };
}
async function loadData() {
document.getElementById('stDot').className = 'st-dot st-warn';
document.getElementById('stTxt').textContent = '获取数据';
try {
const data = await fetchFromServer();
const symKey = curSym === 'ETH/USDT' ? 'eth' : 'btc';
cache[curSym] = data;
cache[curSym + '_key'] = symKey;
document.getElementById('stDot').className = 'st-dot st-ok';
document.getElementById('stTxt').textContent = '在线';
render();
} catch(e) {
document.getElementById('stDot').className = 'st-dot st-err';
if (e.message && (e.message.includes('Failed to fetch') || e.message.includes('NetworkError'))) {
document.getElementById('stTxt').textContent = IS_CLOUDSTUDIO ? 'HTTPS阻截' : '连接失败';
document.getElementById('csBanner').style.display = IS_CLOUDSTUDIO ? 'block' : 'none';
document.getElementById('mGrid').innerHTML = '<div class="er">⚠️ 无法连接服务器<br><small>请用手机浏览器直接打开<br><a href="http://43.163.225.30:9000/" style="color:var(--green)">http://43.163.225.30:9000/</a></small></div>';
document.getElementById('fList').innerHTML = '<div class="er">连接失败 — 请用直连 IP 访问</div>';
} else {
document.getElementById('stTxt').textContent = '离线';
document.getElementById('mGrid').innerHTML = '<div class="er">数据获取失败: ' + e.message + '</div>';
document.getElementById('fList').innerHTML = '<div class="er">连接失败</div>';
}
}
}
// ============================================================
// 渲染(适配服务器 API 格式)
// ============================================================
function render() {
const raw = cache[curSym];
if (!raw) return;
const key = cache[curSym + '_key'] || 'eth';
const st = raw.struct ? raw.struct[key] : null;
const di = raw.diag ? raw.diag[key] : null;
if (!st || !di) return;
// Price cards
document.getElementById('cPrice').textContent = st.current_price ? '$' + Number(st.current_price).toLocaleString('en-US',{minimumFractionDigits:2}) : '--';
const tEl = document.getElementById('cTrend');
if (st.trend_1d === 'up') tEl.innerHTML = '<span class="ta up">&#x2191;</span> 上升趋势';
else if (st.trend_1d === 'down') tEl.innerHTML = '<span class="ta dn">&#x2193;</span> 下降趋势';
else tEl.innerHTML = '<span class="ta nt">&#x2192;</span> 震荡';
document.getElementById('cSup').textContent = st.support ? '$' + Number(st.support).toLocaleString('en-US',{minimumFractionDigits:2}) : '--';
document.getElementById('cRes').textContent = st.resistance ? '$' + Number(st.resistance).toLocaleString('en-US',{minimumFractionDigits:2}) : '--';
// SR badges
let sr = '';
if (st.support) sr += '<span class="sr-badge sr-support">&#x25BC; 支撑 $' + Number(st.support).toFixed(2) + '</span>';
if (st.resistance) sr += '<span class="sr-badge sr-resist">&#x25B2; 阻力 $' + Number(st.resistance).toFixed(2) + '</span>';
if (st.zone_width_pct) sr += '<span class="sr-badge sr-price">&#x25C6; 带宽 ' + Number(st.zone_width_pct).toFixed(1) + '%</span>';
if (st.price_position_pct !== null && st.price_position_pct !== undefined) sr += '<span class="sr-badge sr-price">&#x25C6; 位置 ' + Number(st.price_position_pct).toFixed(0) + '%</span>';
document.getElementById('srRow').innerHTML = sr;
// Chart
try { renderChart(st); } catch(e) { console.error("Chart error:", e); }
// Condition cards
try { renderConditions(di, st); } catch(e) { console.error("Conditions error:", e); }
}
function renderChart(st) {
const hist = st.price_history_1h;
const p = hist ? hist.close : null;
const ts = hist ? hist.timestamps : null;
if (!p || p.length < 3) return;
const w = 340, h = 200, pad = 20, topPad = 22, botPad = 20;
const cw = w - pad*2, ch = h - topPad - botPad;
const vals = p.concat(st.support || []).concat(st.resistance || []);
const mn = Math.min(...vals), mx = Math.max(...vals);
const rg = (mx - mn) || 1;
const xS = (i) => pad + (i / (p.length - 1)) * cw;
const yS = (v) => topPad + ch - ((v - mn) / rg) * ch;
const pts = p.map((v,i) => xS(i).toFixed(0)+','+yS(v).toFixed(0)).join(' ');
let svg = '<rect width="'+w+'" height="'+h+'" fill="var(--surface)" rx="6"/>';
if (st.support && st.resistance && st.resistance > st.support) {
const supY = yS(st.support), resY = yS(st.resistance);
const zoneH = supY - resY;
svg += '<rect x="'+(w-12)+'" y="'+resY+'" width="6" height="'+zoneH+'" rx="3" fill="rgba(136,136,136,0.12)"/>';
const supplyH = zoneH * 0.35;
svg += '<rect x="'+(w-12)+'" y="'+resY+'" width="6" height="'+supplyH+'" rx="3" fill="rgba(248,81,73,0.2)"/>';
const demandH = zoneH * 0.35;
svg += '<rect x="'+(w-12)+'" y="'+(supY - demandH)+'" width="6" height="'+demandH+'" rx="3" fill="rgba(63,185,80,0.2)"/>';
svg += '<text x="'+pad+'" y="'+(resY + supplyH/2 + 3)+'" fill="var(--red)" font-size="9" opacity="0.6">供给区</text>';
svg += '<text x="'+pad+'" y="'+(supY - demandH/2 + 3)+'" fill="var(--green)" font-size="9" opacity="0.6">需求区</text>';
svg += '<line x1="'+pad+'" y1="'+resY.toFixed(0)+'" x2="'+(w-pad)+'" y2="'+resY.toFixed(0)+'" stroke="var(--red)" stroke-width="1" stroke-dasharray="4,3" opacity="0.8"/>';
svg += '<text x="'+(w-pad-14)+'" y="'+(resY-4)+'" text-anchor="end" fill="var(--red)" font-size="11">阻力 $'+Number(st.resistance).toFixed(0)+'</text>';
svg += '<line x1="'+pad+'" y1="'+supY.toFixed(0)+'" x2="'+(w-pad)+'" y2="'+supY.toFixed(0)+'" stroke="var(--green)" stroke-width="1" stroke-dasharray="4,3" opacity="0.8"/>';
svg += '<text x="'+(w-pad-14)+'" y="'+(supY-4)+'" text-anchor="end" fill="var(--green)" font-size="11">支撑 $'+Number(st.support).toFixed(0)+'</text>';
}
svg += '<polyline points="'+pts+'" fill="none" stroke="var(--blue)" stroke-width="1.5"/>';
const lx = xS(p.length-1), ly = yS(p[p.length-1]);
svg += '<circle cx="'+lx.toFixed(0)+'" cy="'+ly.toFixed(0)+'" r="3.5" fill="var(--blue)" stroke="var(--surface)" stroke-width="1.5"/>';
svg += '<text x="'+(lx).toFixed(0)+'" y="'+(ly-6).toFixed(0)+'" text-anchor="middle" fill="var(--blue)" font-size="10" font-weight="500">$'+Number(p[p.length-1]).toFixed(0)+'</text>';
if (st.swing_points) {
if (st.swing_points.highs) {
st.swing_points.highs.slice(-8).forEach(sp => {
const ix = p.findIndex(c => Math.abs(c - sp.price) / Math.max(sp.price,1) < 0.008);
if (ix >= 0) {
const sx = xS(ix), sy = yS(sp.price);
svg += '<circle cx="'+sx.toFixed(0)+'" cy="'+sy.toFixed(0)+'" r="2.5" fill="var(--green)" opacity="0.6"/>';
}
});
}
if (st.swing_points.lows) {
st.swing_points.lows.slice(-8).forEach(sp => {
const ix = p.findIndex(c => Math.abs(c - sp.price) / Math.max(sp.price,1) < 0.008);
if (ix >= 0) {
const sx = xS(ix), sy = yS(sp.price);
svg += '<circle cx="'+sx.toFixed(0)+'" cy="'+sy.toFixed(0)+'" r="2.5" fill="var(--red)" opacity="0.6"/>';
}
});
}
}
svg += '<text x="'+pad+'" y="'+(topPad-4)+'" fill="var(--text3)" font-size="9">$'+Number(mx).toFixed(0)+'</text>';
svg += '<text x="'+pad+'" y="'+(h-botPad+14)+'" fill="var(--text3)" font-size="9">$'+Number(mn).toFixed(0)+'</text>';
if (ts && ts.length > 1) {
svg += '<text x="'+pad+'" y="'+(h-2)+'" fill="var(--text3)" font-size="9">'+(ts[0]||'').slice(0,11).replace(' ',' ')+'</text>';
svg += '<text x="'+(w-pad)+'" y="'+(h-2)+'" text-anchor="end" fill="var(--text3)" font-size="9">'+(ts[ts.length-1]||'').slice(0,11).replace(' ',' ')+'</text>';
}
document.getElementById('chSvg').innerHTML = svg;
document.getElementById('chP').textContent = curSym;
}
// ============================================================
// 条件卡片渲染V1.2 — 同时显示做多+做空两套条件)
// ============================================================
function renderConditions(di, st) {
const el = document.getElementById('condCard');
if (!di || !di.filters || !st) { el.innerHTML = '<div class="ld">加载中...</div>'; return; }
const f = di.filters;
const inSupply = f['in_supply_1h'] ? f['in_supply_1h'].pass : false;
const inDemand = f['in_demand_1h'] ? f['in_demand_1h'].pass : false;
const zoneLabel = inSupply ? '供给区' : (inDemand ? '需求区' : '中间区');
const zonePct = st.price_position_pct !== null && st.price_position_pct !== undefined ? st.price_position_pct : null;
const distToSup = st.current_price && st.support ? (st.current_price - st.support) : null;
const distToRes = st.resistance && st.current_price ? (st.resistance - st.current_price) : null;
const shortChecks = [
{key:'trend_down_1d', label:'D1 下降趋势'},
{key:'in_supply_1h', label:'在供给区'},
{key:'resistance_alive_1h', label:'阻力有效'},
{key:'strong_downtrend_4h', label:'4H 下降趋势'},
{key:'short_stop_distance', label:'止损距离合理'},
];
const longChecks = [
{key:'trend_up_1d', label:'D1 上升趋势'},
{key:'in_demand_1h', label:'在需求区'},
{key:'support_alive_1h', label:'支撑有效'},
{key:'strong_uptrend_4h', label:'4H 上升趋势'},
{key:'long_stop_distance', label:'止损距离合理'},
];
function renderCheckGroup(checks, title, titleColor) {
let gHtml = '<div style="margin-bottom:8px">';
gHtml += '<div style="font-size:12px;font-weight:500;color:'+titleColor+';margin-bottom:4px">'+title+'</div>';
gHtml += '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px">';
let passN = 0;
checks.forEach(c => {
const fl = f[c.key];
if (!fl) return;
if (fl.pass) passN++;
const bg = fl.pass ? 'rgba(63,185,80,0.12)' : 'rgba(248,81,73,0.08)';
const cl = fl.pass ? 'var(--green)' : 'var(--red)';
const icon = fl.pass ? '&#10003;' : '&#10007;';
gHtml += '<span style="padding:3px 8px;border-radius:4px;background:'+bg+';color:'+cl+';font-size:11px;font-weight:500">'+icon+' '+c.label+'</span>';
});
gHtml += '</div>';
const allPass = passN === checks.length;
const barCls = allPass ? 'cb rdy' : 'cb no';
gHtml += '<div class="'+barCls+'" style="margin-bottom:0;font-size:13px;padding:8px">'+(allPass ? '&#x2705; '+title+'条件全部通过' : '&#x274C; '+title+'条件 '+passN+'/'+checks.length+' 通过')+'</div>';
gHtml += '</div>';
return {html: gHtml, passN: passN, total: checks.length};
}
let html = '';
html += '<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 10px;background:var(--surface);border-radius:8px;border:0.5px solid var(--border);margin-bottom:6px;">';
html += '<div style="font-size:12px;color:var(--text)">当前 <strong style="color:var(--blue)">$'+Number(st.current_price).toFixed(0)+'</strong>';
if (zonePct !== null) html += ' <span style="color:var(--text3);margin:0 4px">&#183;</span> '+zoneLabel+''+Number(zonePct).toFixed(0)+'%';
html += '</div>';
html += '<div style="display:flex;gap:4px;">';
if (distToRes !== null) html += '<span style="padding:2px 8px;border-radius:4px;font-size:11px;background:rgba(248,81,73,0.12);color:var(--red);font-weight:500">距阻力 $'+Number(distToRes).toFixed(0)+'</span>';
if (distToSup !== null) html += '<span style="padding:2px 8px;border-radius:4px;font-size:11px;background:rgba(63,185,80,0.08);color:var(--green)">距支撑 $'+Number(distToSup).toFixed(0)+'</span>';
html += '</div></div>';
// 做空条件组
const shortResult = renderCheckGroup(shortChecks, '&#x2193; 做空条件(需全部通过)', 'var(--red)');
html += shortResult.html;
// 做多条件组
const longResult = renderCheckGroup(longChecks, '&#x2191; 做多条件(需全部通过)', 'var(--green)');
html += longResult.html;
// D1 趋势状态提示
const d1Up = f['trend_up_1d'] ? f['trend_up_1d'].pass : false;
const d1Down = f['trend_down_1d'] ? f['trend_down_1d'].pass : false;
if (!d1Up && !d1Down) {
html += '<div style="padding:8px;margin-top:6px;background:rgba(210,153,34,0.1);border:0.5px solid var(--yellow);border-radius:6px;font-size:12px;color:var(--yellow);text-align:center">&#x26A0; D1 趋势处于<strong>震荡</strong> — 做多做空总闸门均关闭,策略等待方向明确</div>';
}
el.innerHTML = html;
// Also update diagnosis tab
const bar = document.getElementById('conclBar');
if (bar) {
const shortOk = shortResult.passN === shortResult.total;
const longOk = longResult.passN === longResult.total;
if (shortOk) { bar.className = 'cb rdy'; bar.textContent = '做空条件全部通过'; }
else if (longOk) { bar.className = 'cb rdy'; bar.textContent = '做多条件全部通过'; }
else { bar.className = 'cb no'; bar.textContent = '无方向满足 — 做空 '+shortResult.passN+'/5做多 '+longResult.passN+'/5'; }
}
const fl = document.getElementById('fList');
if (fl) {
let diagHtml = '';
diagHtml += '<div class="sec-title">&#x2191; 做多过滤</div>';
longChecks.forEach(c => {
const fl2 = f[c.key];
if (!fl2) return;
diagHtml += '<div class="fi"><div><div class="nm">'+c.label+'</div><div class="ds">'+fl2.desc+'</div></div><div class="st '+(fl2.pass?'st-p':'st-f')+'">'+(fl2.pass?'通过':'拒绝')+'</div></div>';
});
diagHtml += '<div class="sec-title" style="margin-top:8px">&#x2193; 做空过滤</div>';
shortChecks.forEach(c => {
const fl2 = f[c.key];
if (!fl2) return;
diagHtml += '<div class="fi"><div><div class="nm">'+c.label+'</div><div class="ds">'+fl2.desc+'</div></div><div class="st '+(fl2.pass?'st-p':'st-f')+'">'+(fl2.pass?'通过':'拒绝')+'</div></div>';
});
fl.innerHTML = diagHtml;
}
}
let tradeInterval = null;
function swTab(n) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tc').forEach(t => t.classList.remove('active'));
const tabEl = document.querySelector(`.tab[onclick="swTab('${n}')"]`);
if (tabEl) tabEl.classList.add('active');
const contentEl = document.getElementById('tab-' + (n==='st'?'st':n==='diag'?'diag':'trade'));
if (contentEl) contentEl.classList.add('active');
if (n === 'trade') {
loadTradeData();
if (tradeInterval) clearInterval(tradeInterval);
tradeInterval = setInterval(loadTradeData, 30000);
} else {
if (tradeInterval) { clearInterval(tradeInterval); tradeInterval = null; }
}
}
function swSym(s) {
curSym = s;
document.querySelectorAll('.sb').forEach(b => b.classList.remove('ac'));
document.querySelector('.sb[onclick="swSym(\''+s+'\')"]').classList.add('ac');
if (cache[s]) render(); else loadData();
}
function refresh() { loadData(); }
// ============================================================
// 交易监测Tab 3
// ============================================================
let tradeCache = null;
async function loadTradeData() {
const headers = { 'X-API-Key': API_KEY, 'Accept': 'application/json' };
try {
const [accRes, tradeRes, statsRes] = await Promise.all([
fetch(SERVER_URL + '/api/account', { headers }),
fetch(SERVER_URL + '/api/trades?limit=50', { headers }),
fetch(SERVER_URL + '/api/trades/stats', { headers }),
]);
if (!accRes.ok || !tradeRes.ok || !statsRes.ok) throw new Error('API error');
tradeCache = {
account: await accRes.json(),
trades: await tradeRes.json(),
stats: await statsRes.json(),
};
renderTrade();
} catch(e) {
document.getElementById('accGrid').innerHTML = '<div class="er" style="grid-column:1/-1">数据获取失败: ' + e.message + '</div>';
}
}
function renderTrade() {
if (!tradeCache) return;
const a = tradeCache.account.account;
const s = tradeCache.account.stats;
const open = tradeCache.account.open_positions || [];
const trades = tradeCache.trades.trades || [];
const daily = tradeCache.account.daily_pnl || [];
const dist = tradeCache.stats.profit_distribution || [];
document.getElementById('accBalance').textContent = '$' + (a.current_balance || 0).toFixed(2);
const pnlEl = document.getElementById('accPnl');
const pnl = a.total_pnl_pct || 0;
pnlEl.textContent = (pnl >= 0 ? '+' : '') + pnl.toFixed(2) + '%';
pnlEl.className = 'v ' + (pnl > 0 ? 'gn' : pnl < 0 ? 'rd' : '');
document.getElementById('accWinRate').textContent = (s.win_rate || 0) + '%';
document.getElementById('accTrades').textContent = s.total_trades || 0;
document.getElementById('accStrategy').textContent = '策略: ' + (a.strategy || '--');
document.getElementById('accUpdate').textContent = '更新: ' + new Date().toLocaleTimeString('zh-CN',{hour:'2-digit',minute:'2-digit'});
let posHtml = '';
if (open.length === 0) {
posHtml = '<div class="empty-state">无持仓</div>';
} else {
open.forEach(p => {
const dir = p.is_short ? 'SHORT' : 'LONG';
const dirCls = p.is_short ? 'pos-short' : 'pos-long';
const pct = p.unrealized_pnl_pct ? (p.unrealized_pnl_pct * 100).toFixed(2) + '%' : '--';
const pctCls = p.unrealized_pnl_pct > 0 ? 'win' : p.unrealized_pnl_pct < 0 ? 'lose' : '';
posHtml += '<div class="tr">'
+ '<div class="tp">'
+ '<span><span class="pos-badge ' + dirCls + '">' + dir + '</span> ' + p.pair + '</span>'
+ '<span class="pnl ' + pctCls + '">' + pct + '</span>'
+ '</div>'
+ '<div class="td">'
+ '<span>入场: $' + (p.open_rate || 0).toFixed(2) + '</span>'
+ '<span>数量: ' + (p.amount || 0).toFixed(4) + '</span>'
+ '<span>止损: $' + (p.stop_loss || 0).toFixed(2) + '</span>'
+ '</div>'
+ '</div>';
});
}
document.getElementById('openPos').innerHTML = posHtml;
document.getElementById('tradeTotal').textContent = tradeCache.trades.pagination.total;
let tbody = '';
if (trades.length === 0) {
tbody = '<tr><td colspan="6" class="empty-state">暂无交易记录(策略刚开始运行或尚未开仓)</td></tr>';
} else {
trades.forEach(t => {
const dir = t.is_short ? '空' : '多';
const dirCls = t.is_short ? 'lose' : 'win';
const pnlPct = t.close_profit ? (t.close_profit * 100).toFixed(2) + '%' : '--';
const pnlCls = (t.close_profit || 0) > 0 ? 'win' : (t.close_profit || 0) < 0 ? 'lose' : '';
const openTime = t.open_date ? t.open_date.slice(5,16).replace('T',' ') : '--';
const exit = t.exit_reason || (t.is_open ? '持仓中' : '--');
tbody += '<tr>'
+ '<td>' + openTime + '</td>'
+ '<td class="' + dirCls + '">' + dir + '</td>'
+ '<td class="num">$' + (t.open_rate || 0).toFixed(2) + '</td>'
+ '<td class="num">' + (t.close_rate ? '$' + t.close_rate.toFixed(2) : '持仓中') + '</td>'
+ '<td class="num ' + pnlCls + '">' + pnlPct + '</td>'
+ '<td>' + exit + '</td>'
+ '</tr>';
});
}
document.getElementById('tradeBody').innerHTML = tbody;
renderDailyPnl(daily);
renderDistChart(dist);
}
function renderDailyPnl(daily) {
const svg = document.getElementById('pnlSvg');
const w = 340, h = 120, pad = 20;
if (!daily || daily.length < 2) {
svg.innerHTML = '<rect width="'+w+'" height="'+h+'" fill="var(--surface)" rx="6"/><text x="170" y="60" text-anchor="middle" fill="var(--text3)" font-size="12">暂无数据</text>';
return;
}
const rev = daily.slice().reverse();
const vals = rev.map(d => d.pnl_abs || 0);
const mn = Math.min(0, ...vals), mx = Math.max(0, ...vals);
const rg = (mx - mn) || 1;
const cw = w - pad*2, ch = h - pad*2 - 10;
const bw = rev.length > 1 ? Math.min(cw / rev.length, 20) : 10;
let bars = '';
const zero = pad + ch - ((0 - mn) / rg) * ch;
rev.forEach((d, i) => {
const x = pad + i * (cw / Math.max(rev.length - 1, 1));
const v = d.pnl_abs || 0;
const barH = Math.abs(v) / rg * ch;
const y = v >= 0 ? zero - barH : zero;
const color = v >= 0 ? 'var(--green)' : 'var(--red)';
bars += '<rect x="' + (x - bw/2) + '" y="' + y + '" width="' + bw + '" height="' + barH + '" fill="' + color + '" opacity="0.8" rx="1"/>';
});
svg.innerHTML = '<rect width="'+w+'" height="'+h+'" fill="var(--surface)" rx="6"/>'
+ '<line x1="'+pad+'" y1="'+zero+'" x2="'+(w-pad)+'" y2="'+zero+'" stroke="var(--border)" stroke-width="0.5"/>'
+ bars
+ '<text x="'+pad+'" y="'+(h-4)+'" fill="var(--text3)" font-size="9">' + rev[0].day + '</text>'
+ '<text x="'+(w-pad)+'" y="'+(h-4)+'" text-anchor="end" fill="var(--text3)" font-size="9">' + rev[rev.length-1].day + '</text>';
}
function renderDistChart(dist) {
const svg = document.getElementById('distSvg');
const w = 340, h = 100, pad = 30;
if (!dist || dist.length === 0) {
svg.innerHTML = '<rect width="'+w+'" height="'+h+'" fill="var(--surface)" rx="6"/><text x="170" y="50" text-anchor="middle" fill="var(--text3)" font-size="12">暂无数据</text>';
return;
}
const maxCount = Math.max(...dist.map(d => d.count || 0), 1);
const cw = w - pad*2, ch = h - pad - 16;
const bw = cw / dist.length - 4;
let bars = '';
dist.forEach((d, i) => {
const x = pad + i * (cw / dist.length) + 2;
const barH = ((d.count || 0) / maxCount) * ch;
const y = h - 16 - barH;
const isLoss = d.profit_range && (d.profit_range.startsWith('\u2264') || d.profit_range.startsWith('-'));
const color = isLoss ? 'var(--red)' : 'var(--green)';
bars += '<rect x="' + x + '" y="' + y + '" width="' + bw + '" height="' + barH + '" fill="' + color + '" opacity="0.7" rx="1"/>';
bars += '<text x="' + (x + bw/2) + '" y="' + (h - 2) + '" text-anchor="middle" fill="var(--text3)" font-size="7" transform="rotate(-45,'+(x+bw/2)+','+(h-2)+')">' + (d.profit_range || '') + '</text>';
});
svg.innerHTML = '<rect width="'+w+'" height="'+h+'" fill="var(--surface)" rx="6"/>' + bars;
}
document.addEventListener('DOMContentLoaded', () => {
if (IS_CLOUDSTUDIO) {
document.getElementById('csBanner').style.display = 'block';
}
loadData();
setInterval(loadData, 60000);
});
</script>
</body>
</html>

399
dashboard/indicators.py Normal file
View File

@ -0,0 +1,399 @@
"""
指标计算模块 — 从 v2.2b 策略提取的独立版本
用于 APP 后端实时计算市场结构和信号诊断
"""
import numpy as np
import pandas as pd
def detect_swing_points(
high: pd.Series,
low: pd.Series,
window: int = 5,
) -> tuple[pd.Series, pd.Series]:
"""检测 Swing High / Swing Low与 v2.2b 策略一致"""
n = len(high)
sh = pd.Series(np.nan, index=high.index, dtype=float)
sl = pd.Series(np.nan, index=low.index, dtype=float)
for i in range(window, n - window):
if high.iloc[i] > high.iloc[i - window : i].max() and high.iloc[i] > high.iloc[i + 1 : i + window + 1].max():
sh.iloc[i] = high.iloc[i]
if low.iloc[i] < low.iloc[i - window : i].min() and low.iloc[i] < low.iloc[i + 1 : i + window + 1].min():
sl.iloc[i] = low.iloc[i]
return sh, sl
def build_structure(
high: pd.Series,
low: pd.Series,
close: pd.Series,
swing_high: pd.Series,
swing_low: pd.Series,
) -> pd.DataFrame:
"""构建价格结构趋势方向、S/R 位、供需区),与 v2.2b 一致"""
n = len(high)
trend_up_arr = np.full(n, False)
trend_down_arr = np.full(n, False)
nearest_support = np.full(n, np.nan)
nearest_resistance = np.full(n, np.nan)
in_demand_zone = np.full(n, False)
in_supply_zone = np.full(n, False)
sh_prices = []
sl_prices = []
for i in range(n):
if pd.notna(swing_high.iloc[i]):
sh_prices.append(swing_high.iloc[i])
if len(sh_prices) > 4:
sh_prices.pop(0)
if pd.notna(swing_low.iloc[i]):
sl_prices.append(swing_low.iloc[i])
if len(sl_prices) > 4:
sl_prices.pop(0)
if len(sh_prices) >= 2 and len(sl_prices) >= 2:
if sh_prices[-1] > sh_prices[-2] and sl_prices[-1] > sl_prices[-2]:
trend_up_arr[i] = True
elif sh_prices[-1] < sh_prices[-2] and sl_prices[-1] < sl_prices[-2]:
trend_down_arr[i] = True
elif i > 0:
trend_up_arr[i] = trend_up_arr[i - 1]
trend_down_arr[i] = trend_down_arr[i - 1]
elif i > 0:
trend_up_arr[i] = trend_up_arr[i - 1]
trend_down_arr[i] = trend_down_arr[i - 1]
if sl_prices:
nearest_support[i] = sl_prices[-1]
if sh_prices:
nearest_resistance[i] = sh_prices[-1]
c = close.iloc[i]
if not np.isnan(nearest_support[i]) and not np.isnan(nearest_resistance[i]):
zone_range = nearest_resistance[i] - nearest_support[i]
if zone_range > 0:
pos_pct = (c - nearest_support[i]) / zone_range
in_demand_zone[i] = pos_pct < 0.35
in_supply_zone[i] = pos_pct > 0.65
return pd.DataFrame({
"trend_up": trend_up_arr,
"trend_down": trend_down_arr,
"support": nearest_support,
"resistance": nearest_resistance,
"in_demand": in_demand_zone,
"in_supply": in_supply_zone,
}, index=high.index)
def compute_trend_strength(
high: pd.Series,
low: pd.Series,
swing_high: pd.Series,
swing_low: pd.Series,
min_strength: float = -0.20,
) -> pd.DataFrame:
"""计算趋势强度4H Swing Point 间距变化),与 v2.2b 一致"""
sh_prices = []
sl_prices = []
trend_strength_up = np.full(len(high), np.nan)
trend_strength_down = np.full(len(high), np.nan)
strong_uptrend = np.full(len(high), False)
strong_downtrend = np.full(len(high), False)
for i in range(len(high)):
if pd.notna(swing_high.iloc[i]):
sh_prices.append(swing_high.iloc[i])
if len(sh_prices) > 4:
sh_prices.pop(0)
if pd.notna(swing_low.iloc[i]):
sl_prices.append(swing_low.iloc[i])
if len(sl_prices) > 4:
sl_prices.pop(0)
if len(sh_prices) >= 2 and len(sl_prices) >= 2:
hh_dist = (sh_prices[-1] - sh_prices[-2]) / sh_prices[-2] if sh_prices[-2] > 0 else 0
hl_dist = (sl_prices[-1] - sl_prices[-2]) / sl_prices[-2] if sl_prices[-2] > 0 else 0
trend_strength_up[i] = hh_dist + hl_dist
trend_strength_down[i] = -(hh_dist + hl_dist)
strong_uptrend = pd.Series(trend_strength_up) > min_strength
strong_downtrend = pd.Series(trend_strength_down) > min_strength
return pd.DataFrame({
"trend_strength_up": pd.Series(trend_strength_up, index=high.index),
"trend_strength_down": pd.Series(trend_strength_down, index=high.index),
"strong_uptrend": strong_uptrend.values,
"strong_downtrend": strong_downtrend.values,
}, index=high.index)
def compute_support_alive(
low: pd.Series,
close: pd.Series,
support: pd.Series,
) -> pd.Series:
"""检查支撑是否活着(最近测试过没跌破),与 v2.2b 一致"""
touched = (
(low <= support * 1.005) &
(low >= support * 0.995)
)
held = close > support
tested_and_held = touched & held
return tested_and_held.rolling(3, min_periods=1).max() > 0
def compute_resistance_alive(
high: pd.Series,
close: pd.Series,
resistance: pd.Series,
) -> pd.Series:
"""检查阻力是否活着(最近测试过没突破),与 v2.2b 一致"""
touched = (
(high >= resistance * 0.995) &
(high <= resistance * 1.005)
)
held = close < resistance
tested_and_held = touched & held
return tested_and_held.rolling(3, min_periods=1).max() > 0
def detect_candle_patterns(
open_: pd.Series,
high: pd.Series,
low: pd.Series,
close: pd.Series,
pin_bar_wick_ratio: float = 0.6,
) -> dict:
"""检测 K 线形态,与 v2.2b 一致"""
body = (close - open_).abs()
total_range = (high - low).replace(0, 0.0001)
upper_wick = high - close.where(close > open_, open_)
lower_wick = open_.where(close > open_, close) - low
is_pin = (upper_wick + lower_wick) / total_range > pin_bar_wick_ratio
bullish_pin = is_pin & (close > open_) & (lower_wick > upper_wick)
bearish_pin = is_pin & (close < open_) & (upper_wick > lower_wick)
prev_open = open_.shift(1)
prev_close = close.shift(1)
bullish_engulf = (close > prev_open) & (open_ < prev_close) & (close > open_)
bearish_engulf = (close < prev_open) & (open_ > prev_close) & (close < open_)
return {
"bullish_pinbar": bullish_pin,
"bearish_pinbar": bearish_pin,
"bullish_signal": bullish_pin | bullish_engulf,
"bearish_signal": bearish_pin | bearish_engulf,
}
def compute_all_indicators(
df_1h: pd.DataFrame,
df_4h: pd.DataFrame,
df_1d: pd.DataFrame,
params: dict = None,
) -> dict:
"""
完整计算所有指标的入口函数
返回 APP 需要的全部数据
params 可选参数:
swing_lookback_d1: int (default 5)
swing_lookback_h4: int (default 8)
pin_bar_wick_ratio: float (default 0.6)
max_stop_dist: float (default 0.50 = 5%)
trend_strength_min: float (default -0.20)
"""
if params is None:
params = {}
swing_lookback_d1 = params.get("swing_lookback_d1", 10) # v2.2d 策略默认 10
swing_lookback_h4 = params.get("swing_lookback_h4", 8)
swing_lookback_1h = params.get("swing_lookback_1h", 5)
pin_bar_wick_ratio = params.get("pin_bar_wick_ratio", 0.6)
max_stop_dist = params.get("max_stop_dist", 0.50)
trend_strength_min = params.get("trend_strength_min", -0.20)
# ---- D1 结构 ----
sh_d1, sl_d1 = detect_swing_points(df_1d["high"], df_1d["low"], swing_lookback_d1)
struct_d1 = build_structure(df_1d["high"], df_1d["low"], df_1d["close"], sh_d1, sl_d1)
trend_up_1d = bool(struct_d1["trend_up"].iloc[-1]) if len(struct_d1) > 0 else False
trend_down_1d = bool(struct_d1["trend_down"].iloc[-1]) if len(struct_d1) > 0 else False
# ---- D1 震荡检测(基于最近两个 swing 点,修复 build_structure 继承前值的误导) ----
sh_d1_valid = sh_d1.dropna()
sl_d1_valid = sl_d1.dropna()
if len(sh_d1_valid) >= 2 and len(sl_d1_valid) >= 2:
last_sh = sh_d1_valid.iloc[-1]
prev_sh = sh_d1_valid.iloc[-2]
last_sl = sl_d1_valid.iloc[-1]
prev_sl = sl_d1_valid.iloc[-2]
is_uptrend = last_sh > prev_sh and last_sl > prev_sl
is_downtrend = last_sh < prev_sh and last_sl < prev_sl
if not is_uptrend and not is_downtrend:
trend_up_1d = False
trend_down_1d = False
# ---- 4H 结构(仅趋势强度) ----
sh_4h, sl_4h = detect_swing_points(df_4h["high"], df_4h["low"], swing_lookback_h4)
struct_4h = build_structure(df_4h["high"], df_4h["low"], df_4h["close"], sh_4h, sl_4h)
strength_4h = compute_trend_strength(
df_4h["high"], df_4h["low"], sh_4h, sl_4h,
min_strength=trend_strength_min,
)
strong_uptrend_4h = bool(strength_4h["strong_uptrend"].iloc[-1]) if len(strength_4h) > 0 else False
strong_downtrend_4h = bool(strength_4h["strong_downtrend"].iloc[-1]) if len(strength_4h) > 0 else False
trend_strength_up_val = float(strength_4h["trend_strength_up"].iloc[-1]) if len(strength_4h) > 0 else 0
trend_strength_down_val = float(strength_4h["trend_strength_down"].iloc[-1]) if len(strength_4h) > 0 else 0
# ---- 1H 结构S/R 适配 v2.2c ----
sh_1h, sl_1h = detect_swing_points(df_1h["high"], df_1h["low"], swing_lookback_1h)
struct_1h = build_structure(df_1h["high"], df_1h["low"], df_1h["close"], sh_1h, sl_1h)
last_1h_s = struct_1h.iloc[-1] if len(struct_1h) > 0 else None
support_1h = float(last_1h_s["support"]) if last_1h_s is not None and pd.notna(last_1h_s["support"]) else None
resistance_1h = float(last_1h_s["resistance"]) if last_1h_s is not None and pd.notna(last_1h_s["resistance"]) else None
in_demand_1h = bool(last_1h_s["in_demand"]) if last_1h_s is not None else False
in_supply_1h = bool(last_1h_s["in_supply"]) if last_1h_s is not None else False
# 1H 活 S/R 检查
support_alive_1h = False
resistance_alive_1h = False
if support_1h is not None:
support_alive_1h = bool(compute_support_alive(df_1h["low"], df_1h["close"], struct_1h["support"]).iloc[-1])
if resistance_1h is not None:
resistance_alive_1h = bool(compute_resistance_alive(df_1h["high"], df_1h["close"], struct_1h["resistance"]).iloc[-1])
# ---- 1H K线形态 ----
last_1h_candle = df_1h.iloc[-1] if len(df_1h) > 0 else None
candle = detect_candle_patterns(
df_1h["open"], df_1h["high"], df_1h["low"], df_1h["close"],
pin_bar_wick_ratio,
)
bullish_signal = bool(candle["bullish_signal"].iloc[-1]) if len(df_1h) > 0 else False
bearish_signal = bool(candle["bearish_signal"].iloc[-1]) if len(df_1h) > 0 else False
bullish_pinbar = bool(candle["bullish_pinbar"].iloc[-1]) if len(df_1h) > 0 else False
bearish_pinbar = bool(candle["bearish_pinbar"].iloc[-1]) if len(df_1h) > 0 else False
# ---- 入场距离1H S/R ----
current_price = float(df_1h["open"].iloc[-1]) if len(df_1h) > 0 else 0
long_stop_dist = None
short_stop_dist = None
if support_1h is not None and support_1h > 0:
long_stop_dist = abs((current_price - support_1h) / current_price)
if resistance_1h is not None and resistance_1h > 0:
short_stop_dist = abs((resistance_1h - current_price) / current_price)
long_dist_ok = long_stop_dist is not None and long_stop_dist <= max_stop_dist and long_stop_dist > 0.003
short_dist_ok = short_stop_dist is not None and short_stop_dist <= max_stop_dist and short_stop_dist > 0.003
# ---- Swing Point 历史1H 用于画图) ----
swing_highs_1h = df_1h[sh_1h.notna()].index.strftime("%Y-%m-%d %H:%M").tolist() if len(sh_1h) > 0 else []
swing_high_prices_1h = [float(x) for x in sh_1h.dropna().tolist()]
swing_lows_1h = df_1h[sl_1h.notna()].index.strftime("%Y-%m-%d %H:%M").tolist() if len(sl_1h) > 0 else []
swing_low_prices_1h = [float(x) for x in sl_1h.dropna().tolist()]
# ---- 信号诊断结果1H S/R ----
diagnosis = {
"market_price": round(current_price, 2),
"support": round(support_1h, 2) if support_1h else None,
"resistance": round(resistance_1h, 2) if resistance_1h else None,
"zone_width_pct": round(abs(resistance_1h - support_1h) / support_1h * 100, 2) if support_1h and resistance_1h else None,
"price_position_in_zone": round((current_price - support_1h) / (resistance_1h - support_1h) * 100, 1) if support_1h and resistance_1h and resistance_1h > support_1h else None,
}
filters = {
"trend_up_1d": {
"pass": trend_up_1d,
"desc": "D1 上升趋势HH+HL 都在抬高)",
"value": "上升" if trend_up_1d else ("下降" if trend_down_1d else "震荡"),
},
"in_demand_1h": {
"pass": in_demand_1h,
"desc": "价格在 1H 需求区zone 底部 35%",
"value": f"位置 {diagnosis['price_position_in_zone']}%" if diagnosis['price_position_in_zone'] is not None else "N/A",
},
"long_stop_distance": {
"pass": long_dist_ok,
"desc": f"入场距支撑距离 ({'%.2f' % (long_stop_dist*100) if long_stop_dist else 'N/A'}%)",
"value": f"{'%.2f' % (long_stop_dist*100)}%" if long_stop_dist else "N/A",
},
"support_alive_1h": {
"pass": support_alive_1h,
"desc": "1H 支撑最近被测试过且未跌破",
"value": "有效" if support_alive_1h else "失效/未测试",
},
"strong_uptrend_4h": {
"pass": strong_uptrend_4h,
"desc": "4H 趋势强度(扩张中)",
"value": f"{'%.2f' % (trend_strength_up_val*100)}%",
},
"trend_down_1d": {
"pass": trend_down_1d,
"desc": "D1 下降趋势LH+LL 都在降低)",
"value": "下降" if trend_down_1d else ("上升" if trend_up_1d else "震荡"),
},
"in_supply_1h": {
"pass": in_supply_1h,
"desc": "价格在 1H 供给区zone 顶部 65%+",
"value": f"位置 {diagnosis['price_position_in_zone']}%" if diagnosis['price_position_in_zone'] is not None else "N/A",
},
"short_stop_distance": {
"pass": short_dist_ok,
"desc": f"入场距阻力距离 ({'%.2f' % (short_stop_dist*100) if short_stop_dist else 'N/A'}%)",
"value": f"{'%.2f' % (short_stop_dist*100)}%" if short_stop_dist else "N/A",
},
"resistance_alive_1h": {
"pass": resistance_alive_1h,
"desc": "1H 阻力最近被测试过且未突破",
"value": "有效" if resistance_alive_1h else "失效/未测试",
},
"strong_downtrend_4h": {
"pass": strong_downtrend_4h,
"desc": "4H 下降趋势强度(扩张中)",
"value": f"{'%.2f' % (trend_strength_down_val*100)}%",
},
}
# 入场可行性结论1H S/R + 4H 趋势)
long_ok = all([
trend_up_1d, in_demand_1h, long_dist_ok,
support_alive_1h, strong_uptrend_4h,
])
short_ok = all([
trend_down_1d, in_supply_1h, short_dist_ok,
resistance_alive_1h, strong_downtrend_4h,
])
return {
"timestamp": pd.Timestamp.now().isoformat(),
"current_price": round(current_price, 2),
"diagnosis": diagnosis,
"filters": filters,
"can_enter_long": long_ok,
"can_enter_short": short_ok,
"trend_1d": "up" if trend_up_1d else ("down" if trend_down_1d else "neutral"),
"candle_1h": {
"bullish_pinbar": bullish_pinbar,
"bearish_pinbar": bearish_pinbar,
"bullish_signal": bullish_signal,
"bearish_signal": bearish_signal,
},
"swing_points": {
"highs": [{"time": t, "price": p} for t, p in zip(swing_highs_1h[-10:], swing_high_prices_1h[-10:])],
"lows": [{"time": t, "price": p} for t, p in zip(swing_lows_1h[-10:], swing_low_prices_1h[-10:])],
},
"trend_strength_4h": {
"up": round(trend_strength_up_val * 100, 2),
"down": round(trend_strength_down_val * 100, 2),
},
}

499
dashboard/main.py Normal file
View File

@ -0,0 +1,499 @@
"""
Beast Trader API — 市场结构、信号诊断、交易监测数据
"""
import os
import time
import hmac
import hashlib
import json
import sqlite3
import ccxt
import pandas as pd
import numpy as np
from datetime import datetime, timezone, timedelta
from typing import Optional
from fastapi import FastAPI, HTTPException, Request, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel
import uvicorn
from indicators import compute_all_indicators
# =============================================
# 配置
# =============================================
API_KEY = os.environ.get("BEAST_API_KEY", "beast2025")
BINANCE_RATE_LIMIT_DELAY = 1.0 # 秒
DATA_CACHE_SECONDS = 30 # 缓存时间
# Freqtrade 数据库路径(只读)
FREQTRADE_DB = os.environ.get(
"FREQTRADE_DB",
os.path.expanduser("~/freqtrade/user_data/tradesv3.sqlite")
)
INITIAL_WALLET = float(os.environ.get("INITIAL_WALLET", 10000))
app = FastAPI(title="Beast Trader API", version="1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# =============================================
# 缓存
# =============================================
_cache = {"data": None, "timestamp": 0}
# =============================================
# 认证中间件
# =============================================
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
if request.method == "OPTIONS" or request.url.path in ["/api/health", "/", "/index.html"]:
return await call_next(request)
auth = request.headers.get("X-API-Key")
if not auth or auth != API_KEY:
raise HTTPException(status_code=401, detail="Invalid API Key")
return await call_next(request)
# =============================================
# 数据获取
# =============================================
def fetch_binance_data():
"""获取 BTC 和 ETH 的 D1/4H/1H K线数据 + 实时ticker"""
exchange = ccxt.binance({
"enableRateLimit": True,
})
now = exchange.milliseconds()
since_1d = now - 180 * 24 * 60 * 60 * 1000 # 180天 D1
since_4h = now - 60 * 24 * 60 * 60 * 1000 # 60天 4H
since_1h = now - 14 * 24 * 60 * 60 * 1000 # 14天 1H
result = {}
for symbol in ["ETH/USDT", "BTC/USDT"]:
try:
ohlcv_1d = exchange.fetch_ohlcv(symbol, "1d", since_1d, 200)
ohlcv_4h = exchange.fetch_ohlcv(symbol, "4h", since_4h, 400)
ohlcv_1h = exchange.fetch_ohlcv(symbol, "1h", since_1h, 400)
ticker = exchange.fetch_ticker(symbol)
df_1d = pd.DataFrame(ohlcv_1d, columns=["timestamp", "open", "high", "low", "close", "volume"])
df_1d["timestamp"] = pd.to_datetime(df_1d["timestamp"], unit="ms")
df_1d.set_index("timestamp", inplace=True)
df_4h = pd.DataFrame(ohlcv_4h, columns=["timestamp", "open", "high", "low", "close", "volume"])
df_4h["timestamp"] = pd.to_datetime(df_4h["timestamp"], unit="ms")
df_4h.set_index("timestamp", inplace=True)
df_1h = pd.DataFrame(ohlcv_1h, columns=["timestamp", "open", "high", "low", "close", "volume"])
df_1h["timestamp"] = pd.to_datetime(df_1h["timestamp"], unit="ms")
df_1h.set_index("timestamp", inplace=True)
result[symbol] = {"1d": df_1d, "4h": df_4h, "1h": df_1h, "ticker": ticker}
time.sleep(0.5) # rate limit
except Exception as e:
print(f"Error fetching {symbol}: {e}")
return result
def get_cached_data():
"""获取缓存数据或刷新"""
now = time.time()
if now - _cache["timestamp"] < DATA_CACHE_SECONDS and _cache["data"] is not None:
return _cache["data"]
raw = fetch_binance_data()
result = {}
for symbol, frames in raw.items():
try:
computed = compute_all_indicators(frames["1h"], frames["4h"], frames["1d"])
# 用实时ticker覆盖current_price指标里用的是1H open不实时
price = frames.get("ticker", {}).get("last")
if price is not None:
computed["current_price"] = round(float(price), 2)
result[symbol] = computed
# 附加最近K线数据用于图表
result[symbol]["price_history"] = {
"1h": {
"timestamps": frames["1h"].index[-48:].strftime("%m-%d %H:%M").tolist(),
"close": [round(float(x), 2) for x in frames["1h"]["close"].iloc[-48:].tolist()],
}
}
except Exception as e:
print(f"Error computing {symbol}: {e}")
result[symbol] = {"error": str(e)}
_cache["data"] = result
_cache["timestamp"] = now
return result
# =============================================
# API 路由
# =============================================
@app.get("/api/health")
def health():
return {"status": "ok", "time": datetime.now(timezone.utc).isoformat()}
@app.get("/api/market-structure")
def market_structure():
"""
市场结构分析S/R 位、Swing Point、趋势方向、供需区
"""
data = get_cached_data()
eth = data.get("ETH/USDT", {})
btc = data.get("BTC/USDT", {})
if "error" in eth:
raise HTTPException(status_code=500, detail=eth["error"])
return {
"eth": {
"current_price": eth.get("current_price"),
"support": eth.get("diagnosis", {}).get("support"),
"resistance": eth.get("diagnosis", {}).get("resistance"),
"zone_width_pct": eth.get("diagnosis", {}).get("zone_width_pct"),
"price_position_pct": eth.get("diagnosis", {}).get("price_position_in_zone"),
"trend_1d": eth.get("trend_1d"),
"trend_strength_4h": eth.get("trend_strength_4h"),
"swing_points": eth.get("swing_points"),
"price_history_1h": eth.get("price_history", {}).get("1h"),
},
"btc": {
"current_price": btc.get("current_price") if "error" not in btc else None,
"support_4h": btc.get("diagnosis", {}).get("support_4h"),
"resistance_4h": btc.get("diagnosis", {}).get("resistance_4h"),
"trend_1d": btc.get("trend_1d"),
},
"cached_at": datetime.now(timezone.utc).isoformat(),
}
@app.get("/api/signal-diagnosis")
def signal_diagnosis():
"""
信号诊断8 层过滤条件逐一检查
"""
data = get_cached_data()
eth = data.get("ETH/USDT", {})
btc = data.get("BTC/USDT", {})
if "error" in eth:
raise HTTPException(status_code=500, detail=eth["error"])
return {
"eth": {
"can_enter_long": eth.get("can_enter_long"),
"can_enter_short": eth.get("can_enter_short"),
"diagnosis": eth.get("diagnosis"),
"filters": eth.get("filters"),
"candle_1h": eth.get("candle_1h"),
"trend_strength_4h": eth.get("trend_strength_4h"),
},
"btc": {
"can_enter_long": btc.get("can_enter_long"),
"can_enter_short": btc.get("can_enter_short"),
"diagnosis": btc.get("diagnosis"),
"filters": btc.get("filters"),
} if "error" not in btc else {"error": btc.get("error")},
"cached_at": datetime.now(timezone.utc).isoformat(),
}
# =============================================
# 静态文件服务(前端)
# =============================================
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
@app.get("/")
def serve_index():
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
# =============================================
# 交易监测 API读取 freqtrade SQLite 数据库)
# =============================================
def _get_db():
"""获取只读数据库连接"""
conn = sqlite3.connect(FREQTRADE_DB)
conn.row_factory = sqlite3.Row
return conn
def _row_to_dict(row):
"""将 sqlite3.Row 转为 dict处理 datetime 和 boolean"""
if row is None:
return None
d = {}
for k in row.keys():
v = row[k]
if isinstance(v, bytes):
d[k] = v.decode("utf-8", errors="replace")
elif hasattr(v, "isoformat"):
d[k] = v.isoformat() if v else None
else:
d[k] = v
return d
@app.get("/api/account")
def account_overview():
"""
账户概览:当前余额、盈亏统计、策略信息
数据来源freqtrade SQLite 数据库 (wallet_history + trades)
"""
try:
conn = _get_db()
# 最新钱包余额
wallet = conn.execute(
"SELECT * FROM wallet_history ORDER BY timestamp DESC LIMIT 1"
).fetchone()
# 交易统计
stats = conn.execute("""
SELECT
COUNT(*) as total_trades,
SUM(CASE WHEN is_open = 1 THEN 1 ELSE 0 END) as open_trades,
SUM(CASE WHEN is_open = 0 THEN 1 ELSE 0 END) as closed_trades,
SUM(CASE WHEN is_open = 0 AND close_profit > 0 THEN 1 ELSE 0 END) as winning_trades,
SUM(CASE WHEN is_open = 0 AND close_profit <= 0 THEN 1 ELSE 0 END) as losing_trades,
ROUND(SUM(close_profit_abs), 8) as total_profit_abs,
ROUND(AVG(CASE WHEN is_open = 0 THEN close_profit ELSE NULL END), 6) as avg_profit_pct,
ROUND(MAX(CASE WHEN is_open = 0 THEN close_profit ELSE NULL END), 6) as best_trade_pct,
ROUND(MIN(CASE WHEN is_open = 0 THEN close_profit ELSE NULL END), 6) as worst_trade_pct
FROM trades
""").fetchone()
# 当前持仓详情
open_positions = conn.execute("""
SELECT id, pair, is_short, open_rate, stake_amount, amount,
open_date, stop_loss, max_rate, min_rate,
ROUND((open_rate - ?) / open_rate * 100, 4) as unrealized_pnl_pct
FROM trades
WHERE is_open = 1
ORDER BY open_date DESC
""", [0]).fetchall() # placeholder, real current price handled in response
# 策略名称
strategy_row = conn.execute(
"SELECT DISTINCT strategy FROM trades WHERE strategy IS NOT NULL ORDER BY id DESC LIMIT 1"
).fetchone()
strategy_name = strategy_row["strategy"] if strategy_row else "v2.2c"
# 近期每日盈亏近30天
daily_pnl = conn.execute("""
SELECT DATE(close_date) as day,
ROUND(SUM(close_profit_abs), 8) as pnl_abs,
COUNT(*) as trade_count,
SUM(CASE WHEN close_profit > 0 THEN 1 ELSE 0 END) as wins
FROM trades
WHERE is_open = 0 AND close_date IS NOT NULL
GROUP BY DATE(close_date)
ORDER BY day DESC
LIMIT 30
""").fetchall()
conn.close()
# 计算当前余额
if wallet:
current_balance = wallet["total_quote"] or wallet["balance"]
last_update = _row_to_dict(wallet)["timestamp"]
else:
current_balance = INITIAL_WALLET
last_update = None
# 计算总收益
total_pnl_pct = round((current_balance - INITIAL_WALLET) / INITIAL_WALLET * 100, 2) if current_balance else 0
# 胜率
closed = stats["closed_trades"] or 0
wins = stats["winning_trades"] or 0
win_rate = round(wins / closed * 100, 1) if closed > 0 else 0
return {
"account": {
"initial_wallet": INITIAL_WALLET,
"current_balance": round(current_balance, 2),
"total_pnl_pct": total_pnl_pct,
"total_pnl_abs": round(current_balance - INITIAL_WALLET, 2),
"last_update": last_update,
"strategy": strategy_name,
},
"stats": {
"total_trades": stats["total_trades"] or 0,
"open_trades": stats["open_trades"] or 0,
"closed_trades": closed,
"win_rate": win_rate,
"wins": wins,
"losses": stats["losing_trades"] or 0,
"avg_profit_pct": stats["avg_profit_pct"],
"best_trade_pct": stats["best_trade_pct"],
"worst_trade_pct": stats["worst_trade_pct"],
"total_profit_abs": stats["total_profit_abs"] or 0,
},
"open_positions": [_row_to_dict(r) for r in open_positions],
"daily_pnl": [_row_to_dict(r) for r in daily_pnl],
"cached_at": datetime.now(timezone.utc).isoformat(),
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
@app.get("/api/trades")
def trade_list(
limit: int = Query(default=30, ge=1, le=200),
offset: int = Query(default=0, ge=0),
status: str = Query(default="all", pattern="^(all|open|closed)$"),
sort: str = Query(default="desc", pattern="^(asc|desc)$"),
):
"""
交易列表:支持分页和状态筛选
"""
try:
conn = _get_db()
where_clause = ""
if status == "open":
where_clause = "WHERE is_open = 1"
elif status == "closed":
where_clause = "WHERE is_open = 0"
order = "DESC" if sort == "desc" else "ASC"
rows = conn.execute(f"""
SELECT id, pair, is_open, is_short, open_rate, close_rate,
close_profit, close_profit_abs, stake_amount, amount,
open_date, close_date, exit_reason, strategy,
max_rate, min_rate, stop_loss, initial_stop_loss,
enter_tag, leverage
FROM trades
{where_clause}
ORDER BY open_date {order}
LIMIT ? OFFSET ?
""", [limit, offset]).fetchall()
total = conn.execute(f"SELECT COUNT(*) as cnt FROM trades {where_clause}").fetchone()
conn.close()
return {
"trades": [_row_to_dict(r) for r in rows],
"pagination": {
"total": total["cnt"],
"limit": limit,
"offset": offset,
"has_more": offset + limit < total["cnt"],
},
"cached_at": datetime.now(timezone.utc).isoformat(),
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
@app.get("/api/trades/stats")
def trade_stats():
"""
交易统计:按方向/时间/策略维度的聚合数据
"""
try:
conn = _get_db()
# 按方向
by_direction = conn.execute("""
SELECT
CASE WHEN is_short = 1 THEN 'SHORT' ELSE 'LONG' END as direction,
COUNT(*) as total,
SUM(CASE WHEN is_open = 0 AND close_profit > 0 THEN 1 ELSE 0 END) as wins,
SUM(CASE WHEN is_open = 0 AND close_profit <= 0 THEN 1 ELSE 0 END) as losses,
ROUND(AVG(CASE WHEN is_open = 0 THEN close_profit ELSE NULL END), 6) as avg_profit_pct,
ROUND(SUM(CASE WHEN is_open = 0 THEN close_profit_abs ELSE 0 END), 8) as total_pnl_abs
FROM trades
WHERE is_open = 0
GROUP BY is_short
""").fetchall()
# 按退出原因
by_exit = conn.execute("""
SELECT
exit_reason,
COUNT(*) as count,
ROUND(AVG(close_profit), 6) as avg_profit_pct,
ROUND(SUM(close_profit_abs), 8) as total_pnl_abs
FROM trades
WHERE is_open = 0 AND exit_reason IS NOT NULL
GROUP BY exit_reason
ORDER BY count DESC
LIMIT 10
""").fetchall()
# 月度表现
monthly = conn.execute("""
SELECT
strftime('%Y-%m', open_date) as month,
COUNT(*) as total,
SUM(CASE WHEN close_profit > 0 THEN 1 ELSE 0 END) as wins,
ROUND(AVG(close_profit), 6) as avg_profit_pct,
ROUND(SUM(close_profit_abs), 8) as total_pnl_abs
FROM trades
WHERE is_open = 0
GROUP BY strftime('%Y-%m', open_date)
ORDER BY month DESC
LIMIT 12
""").fetchall()
# 盈亏分布(区间统计)
distribution = conn.execute("""
SELECT
CASE
WHEN close_profit <= -0.10 THEN '≤ -10%'
WHEN close_profit <= -0.05 THEN '-10% ~ -5%'
WHEN close_profit <= -0.02 THEN '-5% ~ -2%'
WHEN close_profit < 0 THEN '-2% ~ 0%'
WHEN close_profit = 0 THEN '0%'
WHEN close_profit <= 0.02 THEN '0% ~ 2%'
WHEN close_profit <= 0.05 THEN '2% ~ 5%'
WHEN close_profit <= 0.10 THEN '5% ~ 10%'
WHEN close_profit <= 0.20 THEN '10% ~ 20%'
ELSE '> 20%'
END as profit_range,
COUNT(*) as count
FROM trades
WHERE is_open = 0
GROUP BY 1
ORDER BY MIN(close_profit)
""").fetchall()
conn.close()
return {
"by_direction": [_row_to_dict(r) for r in by_direction],
"by_exit_reason": [_row_to_dict(r) for r in by_exit],
"monthly": [_row_to_dict(r) for r in monthly],
"profit_distribution": [_row_to_dict(r) for r in distribution],
"cached_at": datetime.now(timezone.utc).isoformat(),
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
# =============================================
# 启动
# =============================================
if __name__ == "__main__":
port = int(os.environ.get("PORT", 8000))
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=False)

22
dashboard/manifest.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "Beast Trader",
"short_name": "BeastTrader",
"description": "ETH/USDT 结构分析 + 信号诊断",
"start_url": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"orientation": "portrait",
"icons": [
{
"src": "/icon.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@ -0,0 +1,6 @@
fastapi
uvicorn
ccxt
pandas
numpy
python-dotenv

View File

@ -0,0 +1,247 @@
<#
.Synopsis
Activate a Python virtual environment for the current PowerShell session.
.Description
Pushes the python executable for a virtual environment to the front of the
$Env:PATH environment variable and sets the prompt to signify that you are
in a Python virtual environment. Makes use of the command line switches as
well as the `pyvenv.cfg` file values present in the virtual environment.
.Parameter VenvDir
Path to the directory that contains the virtual environment to activate. The
default value for this is the parent of the directory that the Activate.ps1
script is located within.
.Parameter Prompt
The prompt prefix to display when this virtual environment is activated. By
default, this prompt is the name of the virtual environment folder (VenvDir)
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
.Example
Activate.ps1
Activates the Python virtual environment that contains the Activate.ps1 script.
.Example
Activate.ps1 -Verbose
Activates the Python virtual environment that contains the Activate.ps1 script,
and shows extra information about the activation as it executes.
.Example
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
Activates the Python virtual environment located in the specified location.
.Example
Activate.ps1 -Prompt "MyPython"
Activates the Python virtual environment that contains the Activate.ps1 script,
and prefixes the current prompt with the specified string (surrounded in
parentheses) while the virtual environment is active.
.Notes
On Windows, it may be required to enable this Activate.ps1 script by setting the
execution policy for the user. You can do this by issuing the following PowerShell
command:
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
For more information on Execution Policies:
https://go.microsoft.com/fwlink/?LinkID=135170
#>
Param(
[Parameter(Mandatory = $false)]
[String]
$VenvDir,
[Parameter(Mandatory = $false)]
[String]
$Prompt
)
<# Function declarations --------------------------------------------------- #>
<#
.Synopsis
Remove all shell session elements added by the Activate script, including the
addition of the virtual environment's Python executable from the beginning of
the PATH variable.
.Parameter NonDestructive
If present, do not remove this function from the global namespace for the
session.
#>
function global:deactivate ([switch]$NonDestructive) {
# Revert to original values
# The prior prompt:
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
}
# The prior PYTHONHOME:
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
}
# The prior PATH:
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
}
# Just remove the VIRTUAL_ENV altogether:
if (Test-Path -Path Env:VIRTUAL_ENV) {
Remove-Item -Path env:VIRTUAL_ENV
}
# Just remove VIRTUAL_ENV_PROMPT altogether.
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
}
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
}
# Leave deactivate function in the global namespace if requested:
if (-not $NonDestructive) {
Remove-Item -Path function:deactivate
}
}
<#
.Description
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
given folder, and returns them in a map.
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
two strings separated by `=` (with any amount of whitespace surrounding the =)
then it is considered a `key = value` line. The left hand string is the key,
the right hand is the value.
If the value starts with a `'` or a `"` then the first and last character is
stripped from the value before being captured.
.Parameter ConfigDir
Path to the directory that contains the `pyvenv.cfg` file.
#>
function Get-PyVenvConfig(
[String]
$ConfigDir
) {
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
# An empty map will be returned if no config file is found.
$pyvenvConfig = @{ }
if ($pyvenvConfigPath) {
Write-Verbose "File exists, parse `key = value` lines"
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
$pyvenvConfigContent | ForEach-Object {
$keyval = $PSItem -split "\s*=\s*", 2
if ($keyval[0] -and $keyval[1]) {
$val = $keyval[1]
# Remove extraneous quotations around a string value.
if ("'""".Contains($val.Substring(0, 1))) {
$val = $val.Substring(1, $val.Length - 2)
}
$pyvenvConfig[$keyval[0]] = $val
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
}
}
}
return $pyvenvConfig
}
<# Begin Activate script --------------------------------------------------- #>
# Determine the containing directory of this script
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
$VenvExecDir = Get-Item -Path $VenvExecPath
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
# Set values required in priority: CmdLine, ConfigFile, Default
# First, get the location of the virtual environment, it might not be
# VenvExecDir if specified on the command line.
if ($VenvDir) {
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
}
else {
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
Write-Verbose "VenvDir=$VenvDir"
}
# Next, read the `pyvenv.cfg` file to determine any required value such
# as `prompt`.
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
# Next, set the prompt from the command line, or the config file, or
# just use the name of the virtual environment folder.
if ($Prompt) {
Write-Verbose "Prompt specified as argument, using '$Prompt'"
}
else {
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
$Prompt = $pyvenvCfg['prompt'];
}
else {
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
$Prompt = Split-Path -Path $venvDir -Leaf
}
}
Write-Verbose "Prompt = '$Prompt'"
Write-Verbose "VenvDir='$VenvDir'"
# Deactivate any currently active virtual environment, but leave the
# deactivate function in place.
deactivate -nondestructive
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
# that there is an activated venv.
$env:VIRTUAL_ENV = $VenvDir
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
Write-Verbose "Setting prompt to '$Prompt'"
# Set the prompt to include the env name
# Make sure _OLD_VIRTUAL_PROMPT is global
function global:_OLD_VIRTUAL_PROMPT { "" }
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
function global:prompt {
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
_OLD_VIRTUAL_PROMPT
}
$env:VIRTUAL_ENV_PROMPT = $Prompt
}
# Clear PYTHONHOME
if (Test-Path -Path Env:PYTHONHOME) {
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
Remove-Item -Path Env:PYTHONHOME
}
# Add the venv to the PATH
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"

View File

@ -0,0 +1,70 @@
# This file must be used with "source bin/activate" *from bash*
# You cannot run it directly
deactivate () {
# reset old environment variables
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
PATH="${_OLD_VIRTUAL_PATH:-}"
export PATH
unset _OLD_VIRTUAL_PATH
fi
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
export PYTHONHOME
unset _OLD_VIRTUAL_PYTHONHOME
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
PS1="${_OLD_VIRTUAL_PS1:-}"
export PS1
unset _OLD_VIRTUAL_PS1
fi
unset VIRTUAL_ENV
unset VIRTUAL_ENV_PROMPT
if [ ! "${1:-}" = "nondestructive" ] ; then
# Self destruct!
unset -f deactivate
fi
}
# unset irrelevant variables
deactivate nondestructive
# on Windows, a path can contain colons and backslashes and has to be converted:
if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then
# transform D:\path\to\venv to /d/path/to/venv on MSYS
# and to /cygdrive/d/path/to/venv on Cygwin
export VIRTUAL_ENV=$(cygpath /home/ubuntu/trading_app/backend/venv)
else
# use the path as-is
export VIRTUAL_ENV=/home/ubuntu/trading_app/backend/venv
fi
_OLD_VIRTUAL_PATH="$PATH"
PATH="$VIRTUAL_ENV/"bin":$PATH"
export PATH
# unset PYTHONHOME if set
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
# could use `if (set -u; : $PYTHONHOME) ;` in bash
if [ -n "${PYTHONHOME:-}" ] ; then
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
unset PYTHONHOME
fi
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
_OLD_VIRTUAL_PS1="${PS1:-}"
PS1='(venv) '"${PS1:-}"
export PS1
VIRTUAL_ENV_PROMPT='(venv) '
export VIRTUAL_ENV_PROMPT
fi
# Call hash to forget past commands. Without forgetting
# past commands the $PATH changes we made may not be respected
hash -r 2> /dev/null

View File

@ -0,0 +1,27 @@
# This file must be used with "source bin/activate.csh" *from csh*.
# You cannot run it directly.
# Created by Davide Di Blasi <davidedb@gmail.com>.
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
# Unset irrelevant variables.
deactivate nondestructive
setenv VIRTUAL_ENV /home/ubuntu/trading_app/backend/venv
set _OLD_VIRTUAL_PATH="$PATH"
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
set _OLD_VIRTUAL_PROMPT="$prompt"
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
set prompt = '(venv) '"$prompt"
setenv VIRTUAL_ENV_PROMPT '(venv) '
endif
alias pydoc python -m pydoc
rehash

View File

@ -0,0 +1,69 @@
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
# (https://fishshell.com/). You cannot run it directly.
function deactivate -d "Exit virtual environment and return to normal shell environment"
# reset old environment variables
if test -n "$_OLD_VIRTUAL_PATH"
set -gx PATH $_OLD_VIRTUAL_PATH
set -e _OLD_VIRTUAL_PATH
end
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
set -e _OLD_VIRTUAL_PYTHONHOME
end
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
set -e _OLD_FISH_PROMPT_OVERRIDE
# prevents error when using nested fish instances (Issue #93858)
if functions -q _old_fish_prompt
functions -e fish_prompt
functions -c _old_fish_prompt fish_prompt
functions -e _old_fish_prompt
end
end
set -e VIRTUAL_ENV
set -e VIRTUAL_ENV_PROMPT
if test "$argv[1]" != "nondestructive"
# Self-destruct!
functions -e deactivate
end
end
# Unset irrelevant variables.
deactivate nondestructive
set -gx VIRTUAL_ENV /home/ubuntu/trading_app/backend/venv
set -gx _OLD_VIRTUAL_PATH $PATH
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
# Unset PYTHONHOME if set.
if set -q PYTHONHOME
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
set -e PYTHONHOME
end
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
# fish uses a function instead of an env var to generate the prompt.
# Save the current fish_prompt function as the function _old_fish_prompt.
functions -c fish_prompt _old_fish_prompt
# With the original prompt function renamed, we can override with our own.
function fish_prompt
# Save the return status of the last command.
set -l old_status $status
# Output the venv prompt; color taken from the blue of the Python logo.
printf "%s%s%s" (set_color 4B8BBE) '(venv) ' (set_color normal)
# Restore the return status of the previous command.
echo "exit $old_status" | .
# Output the original/"old" prompt.
_old_fish_prompt
end
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
set -gx VIRTUAL_ENV_PROMPT '(venv) '
end

8
dashboard/venv/bin/dotenv Executable file
View File

@ -0,0 +1,8 @@
#!/home/ubuntu/trading_app/backend/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from dotenv.__main__ import cli
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli())

8
dashboard/venv/bin/f2py Executable file
View File

@ -0,0 +1,8 @@
#!/home/ubuntu/trading_app/backend/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from numpy.f2py.f2py2e import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
dashboard/venv/bin/fastapi Executable file
View File

@ -0,0 +1,8 @@
#!/home/ubuntu/trading_app/backend/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from fastapi.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
dashboard/venv/bin/idna Executable file
View File

@ -0,0 +1,8 @@
#!/home/ubuntu/trading_app/backend/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from idna.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
dashboard/venv/bin/normalizer Executable file
View File

@ -0,0 +1,8 @@
#!/home/ubuntu/trading_app/backend/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from charset_normalizer.cli import cli_detect
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(cli_detect())

View File

@ -0,0 +1,8 @@
#!/home/ubuntu/trading_app/backend/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from numpy._configtool import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
dashboard/venv/bin/pip Executable file
View File

@ -0,0 +1,8 @@
#!/home/ubuntu/trading_app/backend/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
dashboard/venv/bin/pip3 Executable file
View File

@ -0,0 +1,8 @@
#!/home/ubuntu/trading_app/backend/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

8
dashboard/venv/bin/pip3.12 Executable file
View File

@ -0,0 +1,8 @@
#!/home/ubuntu/trading_app/backend/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from pip._internal.cli.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

1
dashboard/venv/bin/python Symbolic link
View File

@ -0,0 +1 @@
python3

1
dashboard/venv/bin/python3 Symbolic link
View File

@ -0,0 +1 @@
/usr/bin/python3

View File

@ -0,0 +1 @@
python3

8
dashboard/venv/bin/uvicorn Executable file
View File

@ -0,0 +1,8 @@
#!/home/ubuntu/trading_app/backend/venv/bin/python3
# -*- coding: utf-8 -*-
import re
import sys
from uvicorn.main import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())

View File

@ -0,0 +1,239 @@
# don't import any costly modules
import os
import sys
report_url = (
"https://github.com/pypa/setuptools/issues/new?template=distutils-deprecation.yml"
)
def warn_distutils_present():
if 'distutils' not in sys.modules:
return
import warnings
warnings.warn(
"Distutils was imported before Setuptools, but importing Setuptools "
"also replaces the `distutils` module in `sys.modules`. This may lead "
"to undesirable behaviors or errors. To avoid these issues, avoid "
"using distutils directly, ensure that setuptools is installed in the "
"traditional way (e.g. not an editable install), and/or make sure "
"that setuptools is always imported before distutils."
)
def clear_distutils():
if 'distutils' not in sys.modules:
return
import warnings
warnings.warn(
"Setuptools is replacing distutils. Support for replacing "
"an already imported distutils is deprecated. In the future, "
"this condition will fail. "
f"Register concerns at {report_url}"
)
mods = [
name
for name in sys.modules
if name == "distutils" or name.startswith("distutils.")
]
for name in mods:
del sys.modules[name]
def enabled():
"""
Allow selection of distutils by environment variable.
"""
which = os.environ.get('SETUPTOOLS_USE_DISTUTILS', 'local')
if which == 'stdlib':
import warnings
warnings.warn(
"Reliance on distutils from stdlib is deprecated. Users "
"must rely on setuptools to provide the distutils module. "
"Avoid importing distutils or import setuptools first, "
"and avoid setting SETUPTOOLS_USE_DISTUTILS=stdlib. "
f"Register concerns at {report_url}"
)
return which == 'local'
def ensure_local_distutils():
import importlib
clear_distutils()
# With the DistutilsMetaFinder in place,
# perform an import to cause distutils to be
# loaded from setuptools._distutils. Ref #2906.
with shim():
importlib.import_module('distutils')
# check that submodules load as expected
core = importlib.import_module('distutils.core')
assert '_distutils' in core.__file__, core.__file__
assert 'setuptools._distutils.log' not in sys.modules
def do_override():
"""
Ensure that the local copy of distutils is preferred over stdlib.
See https://github.com/pypa/setuptools/issues/417#issuecomment-392298401
for more motivation.
"""
if enabled():
warn_distutils_present()
ensure_local_distutils()
class _TrivialRe:
def __init__(self, *patterns) -> None:
self._patterns = patterns
def match(self, string):
return all(pat in string for pat in self._patterns)
class DistutilsMetaFinder:
def find_spec(self, fullname, path, target=None):
# optimization: only consider top level modules and those
# found in the CPython test suite.
if path is not None and not fullname.startswith('test.'):
return None
method_name = 'spec_for_{fullname}'.format(**locals())
method = getattr(self, method_name, lambda: None)
return method()
def spec_for_distutils(self):
if self.is_cpython():
return None
import importlib
import importlib.abc
import importlib.util
try:
mod = importlib.import_module('setuptools._distutils')
except Exception:
# There are a couple of cases where setuptools._distutils
# may not be present:
# - An older Setuptools without a local distutils is
# taking precedence. Ref #2957.
# - Path manipulation during sitecustomize removes
# setuptools from the path but only after the hook
# has been loaded. Ref #2980.
# In either case, fall back to stdlib behavior.
return None
class DistutilsLoader(importlib.abc.Loader):
def create_module(self, spec):
mod.__name__ = 'distutils'
return mod
def exec_module(self, module):
pass
return importlib.util.spec_from_loader(
'distutils', DistutilsLoader(), origin=mod.__file__
)
@staticmethod
def is_cpython():
"""
Suppress supplying distutils for CPython (build and tests).
Ref #2965 and #3007.
"""
return os.path.isfile('pybuilddir.txt')
def spec_for_pip(self):
"""
Ensure stdlib distutils when running under pip.
See pypa/pip#8761 for rationale.
"""
if sys.version_info >= (3, 12) or self.pip_imported_during_build():
return
clear_distutils()
self.spec_for_distutils = lambda: None
@classmethod
def pip_imported_during_build(cls):
"""
Detect if pip is being imported in a build script. Ref #2355.
"""
import traceback
return any(
cls.frame_file_is_setup(frame) for frame, line in traceback.walk_stack(None)
)
@staticmethod
def frame_file_is_setup(frame):
"""
Return True if the indicated frame suggests a setup.py file.
"""
# some frames may not have __file__ (#2940)
return frame.f_globals.get('__file__', '').endswith('setup.py')
def spec_for_sensitive_tests(self):
"""
Ensure stdlib distutils when running select tests under CPython.
python/cpython#91169
"""
clear_distutils()
self.spec_for_distutils = lambda: None
sensitive_tests = (
[
'test.test_distutils',
'test.test_peg_generator',
'test.test_importlib',
]
if sys.version_info < (3, 10)
else [
'test.test_distutils',
]
)
for name in DistutilsMetaFinder.sensitive_tests:
setattr(
DistutilsMetaFinder,
f'spec_for_{name}',
DistutilsMetaFinder.spec_for_sensitive_tests,
)
DISTUTILS_FINDER = DistutilsMetaFinder()
def add_shim():
DISTUTILS_FINDER in sys.meta_path or insert_shim()
class shim:
def __enter__(self) -> None:
insert_shim()
def __exit__(self, exc: object, value: object, tb: object) -> None:
_remove_shim()
def insert_shim():
sys.meta_path.insert(0, DISTUTILS_FINDER)
def _remove_shim():
try:
sys.meta_path.remove(DISTUTILS_FINDER)
except ValueError:
pass
if sys.version_info < (3, 12):
# DistutilsMetaFinder can only be disabled in Python < 3.12 (PEP 632)
remove_shim = _remove_shim

View File

@ -0,0 +1 @@
__import__('_distutils_hack').do_override()

View File

@ -0,0 +1,222 @@
Metadata-Version: 2.4
Name: aiodns
Version: 4.0.4
Summary: Simple DNS resolver for asyncio
Author-email: Saúl Ibarra Corretgé <s@saghul.net>
License-Expression: MIT
Project-URL: repository, https://github.com/aio-libs/aiodns.git
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: POSIX
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Requires-Python: >=3.10
Description-Content-Type: text/x-rst
License-File: LICENSE
Requires-Dist: pycares<6,>=5.0.0
Dynamic: license-file
===============================
Simple DNS resolver for asyncio
===============================
.. image:: https://badge.fury.io/py/aiodns.png
:target: https://pypi.org/project/aiodns/
.. image:: https://github.com/saghul/aiodns/workflows/CI/badge.svg
:target: https://github.com/saghul/aiodns/actions
aiodns provides a simple way for doing asynchronous DNS resolutions using `pycares <https://github.com/saghul/pycares>`_.
Example
=======
.. code:: python
import asyncio
import aiodns
async def main():
resolver = aiodns.DNSResolver()
result = await resolver.query_dns('google.com', 'A')
for record in result.answer:
print(record.data.addr)
asyncio.run(main())
The following query types are supported: A, AAAA, ANY, CAA, CNAME, MX, NAPTR, NS, PTR, SOA, SRV, TXT.
API
===
The API is pretty simple, the following functions are provided in the ``DNSResolver`` class:
* ``query_dns(host, type)``: Do a DNS resolution of the given type for the given hostname. It returns an
instance of ``asyncio.Future``. The result is a ``pycares.DNSResult`` object with ``answer``,
``authority``, and ``additional`` attributes containing lists of ``pycares.DNSRecord`` objects.
Each record has ``type``, ``ttl``, and ``data`` attributes. Check the `pycares documentation
<https://pycares.readthedocs.io/>`_ for details on the data attributes for each record type.
* ``query(host, type)``: **Deprecated** - use ``query_dns()`` instead. This method returns results
in a legacy format compatible with aiodns 3.x for backward compatibility.
* ``gethostbyname(host, socket_family)``: **Deprecated** - use ``getaddrinfo()`` instead.
Do a DNS resolution for the given hostname and the desired type of address family
(i.e. ``socket.AF_INET``). The actual result of the call is a ``asyncio.Future``.
* ``gethostbyaddr(name)``: Make a reverse lookup for an address.
* ``getaddrinfo(host, family, port, proto, type, flags)``: Resolve a host and port into a list of
address info entries.
* ``getnameinfo(sockaddr, flags)``: Resolve a socket address to a host and port.
* ``cancel()``: Cancel all pending DNS queries. All futures will get ``DNSError`` exception set, with
``ARES_ECANCELLED`` errno.
* ``close()``: Close the resolver. This releases all resources and cancels any pending queries. It must be called
when the resolver is no longer needed (e.g., application shutdown). The resolver should only be closed from the
event loop that created the resolver.
Migrating from aiodns 3.x
=========================
aiodns 4.x introduces a new ``query_dns()`` method that returns native pycares 5.x result types.
See the `pycares documentation <https://pycares.readthedocs.io/latest/channel.html#pycares.Channel.query>`_
for details on the result types. The old ``query()`` method is deprecated but continues to work
for backward compatibility.
.. code:: python
# Old API (deprecated)
result = await resolver.query('example.com', 'MX')
for record in result:
print(record.host, record.priority)
# New API (recommended)
result = await resolver.query_dns('example.com', 'MX')
for record in result.answer:
print(record.data.exchange, record.data.priority)
Future migration to aiodns 5.x
------------------------------
The temporary ``query_dns()`` naming allows gradual migration without breaking changes:
+-----------+---------------------------------------+--------------------------------------------+
| Version | ``query()`` | ``query_dns()`` |
+===========+=======================================+============================================+
| **4.x** | Deprecated, returns compat types | New API, returns pycares 5.x types |
+-----------+---------------------------------------+--------------------------------------------+
| **5.x** | New API, returns pycares 5.x types | Alias to ``query()`` for back compat |
+-----------+---------------------------------------+--------------------------------------------+
In aiodns 5.x, ``query()`` will become the primary API returning native pycares 5.x types,
and ``query_dns()`` will remain as an alias for backward compatibility. This allows downstream
projects to migrate at their own pace.
Async Context Manager Support
=============================
While not recommended for typical use cases, ``DNSResolver`` can be used as an async context manager
for scenarios where automatic cleanup is desired:
.. code:: python
async with aiodns.DNSResolver() as resolver:
result = await resolver.query_dns('example.com', 'A')
# resolver.close() is called automatically when exiting the context
**Important**: This pattern is discouraged for most applications because ``DNSResolver`` instances
are designed to be long-lived and reused for many queries. Creating and destroying resolvers
frequently adds unnecessary overhead. Use the context manager pattern only when you specifically
need automatic cleanup for short-lived resolver instances, such as in tests or one-off scripts.
Note for Windows users
======================
This library requires the use of an ``asyncio.SelectorEventLoop`` or ``winloop`` on Windows
**only** when using a custom build of ``pycares`` that links against a system-
provided ``c-ares`` library **without** thread-safety support. This is because
non-thread-safe builds of ``c-ares`` are incompatible with the default
``ProactorEventLoop`` on Windows.
If you're using the official prebuilt ``pycares`` wheels on PyPI (version 4.7.0 or
later), which include a thread-safe version of ``c-ares``, this limitation does
**not** apply and can be safely ignored.
The default event loop can be changed as follows (do this very early in your application):
.. code:: python
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
This may have other implications for the rest of your codebase, so make sure to test thoroughly.
Running the test suite
======================
To run the test suite: ``python -m pytest tests/``
Releasing (maintainers only)
============================
Releases are cut from ``master`` and published to PyPI automatically by the
``Release Wheels`` workflow when a GitHub Release is created.
1. **Prepare the release PR.** Bump ``__version__`` in ``aiodns/__init__.py``
and prepend a section to ``ChangeLog`` describing the user-facing changes
since the previous tag, in the same RST style as the existing entries
(``X.Y.Z`` header underlined with ``=``). Open the PR with the title
``Release X.Y.Z`` and merge it once CI is green.
Skip Dependabot bumps for dev tooling and CI actions; keep runtime
dependency bumps such as ``pycares``.
2. **Tag and publish the release.** From a clean checkout of ``master`` that
includes the merged release PR, generate the release notes from
``ChangeLog`` and create the GitHub release in one shot::
python scripts/release-notes.py --target X.Y.Z \
| gh release create vX.Y.Z --repo aio-libs/aiodns \
--title vX.Y.Z --notes-file -
The helper script reads ``__version__`` and the topmost ``ChangeLog``
section and aborts non-zero if they disagree, or if ``--target`` does
not match the current state on disk, so you can't accidentally publish
notes for a version the release PR hasn't actually landed yet.
3. **Watch the wheel build.** Publishing the GitHub release fires
``release-wheels.yml``, which builds wheels + sdist and pushes them to
`PyPI <https://pypi.org/project/aiodns/>`_ via trusted publishing
(no token required). Confirm the run succeeds::
gh run list --repo aio-libs/aiodns --workflow release-wheels.yml --limit 1
Author
======
Saúl Ibarra Corretgé <s@saghul.net>
License
=======
aiodns uses the MIT license, check LICENSE file.
Contributing
============
If you'd like to contribute, fork the project, make a patch and send a pull
request. Have a look at the surrounding code and please, make yours look
alike :-)

View File

@ -0,0 +1,13 @@
aiodns-4.0.4.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
aiodns-4.0.4.dist-info/METADATA,sha256=pVHVT-6YGLsPkHek3T52dJw-JihA1xholEOvymEPDLw,9022
aiodns-4.0.4.dist-info/RECORD,,
aiodns-4.0.4.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
aiodns-4.0.4.dist-info/licenses/LICENSE,sha256=PpHPp2ghq3DSKZgTbWdNIK6IhGOn6KfzdxZs_h7mT-s,1069
aiodns-4.0.4.dist-info/top_level.txt,sha256=5J2m3NWP4dezFZNpQoHaj8mv472iPcUzFboQoqWcEe4,7
aiodns/__init__.py,sha256=C8XPexYSfsN-sHf9hTmRMbGwgD189znOD-sduqQfkKQ,19381
aiodns/__pycache__/__init__.cpython-312.pyc,,
aiodns/__pycache__/compat.cpython-312.pyc,,
aiodns/__pycache__/error.cpython-312.pyc,,
aiodns/compat.py,sha256=lHsaYQ8ANvBpb6iqniMJoxuMXY_Cp-NmGRePvH4D91c,7941
aiodns/error.py,sha256=wYZFONch2rThb6sdMuKh_NUZBLJ5UgezHPwireqhQZE,1225
aiodns/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0

View File

@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: setuptools (82.0.1)
Root-Is-Purelib: true
Tag: py3-none-any

View File

@ -0,0 +1,19 @@
Copyright (C) 2014 by Saúl Ibarra Corretgé
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,550 @@
from __future__ import annotations
import asyncio
import contextlib
import functools
import logging
import socket
import sys
import warnings
import weakref
from collections.abc import Callable, Iterable, Iterator, Sequence
from types import TracebackType
from typing import TYPE_CHECKING, Any, Literal, TypeVar, overload
import pycares
from . import error
from .compat import (
AresHostResult,
AresQueryAAAAResult,
AresQueryAResult,
AresQueryCAAResult,
AresQueryCNAMEResult,
AresQueryMXResult,
AresQueryNAPTRResult,
AresQueryNSResult,
AresQueryPTRResult,
AresQuerySOAResult,
AresQuerySRVResult,
AresQueryTXTResult,
QueryResult,
convert_result,
)
__version__ = '4.0.4'
__all__ = (
'DNSResolver',
'error',
)
_T = TypeVar('_T')
WINDOWS_SELECTOR_ERR_MSG = (
'aiodns needs a SelectorEventLoop on Windows. See more: '
'https://github.com/aio-libs/aiodns#note-for-windows-users'
)
_LOGGER = logging.getLogger(__name__)
query_type_map = {
'A': pycares.QUERY_TYPE_A,
'AAAA': pycares.QUERY_TYPE_AAAA,
'ANY': pycares.QUERY_TYPE_ANY,
'CAA': pycares.QUERY_TYPE_CAA,
'CNAME': pycares.QUERY_TYPE_CNAME,
'MX': pycares.QUERY_TYPE_MX,
'NAPTR': pycares.QUERY_TYPE_NAPTR,
'NS': pycares.QUERY_TYPE_NS,
'PTR': pycares.QUERY_TYPE_PTR,
'SOA': pycares.QUERY_TYPE_SOA,
'SRV': pycares.QUERY_TYPE_SRV,
'TXT': pycares.QUERY_TYPE_TXT,
}
query_class_map = {
'IN': pycares.QUERY_CLASS_IN,
'CHAOS': pycares.QUERY_CLASS_CHAOS,
'HS': pycares.QUERY_CLASS_HS,
'NONE': pycares.QUERY_CLASS_NONE,
'ANY': pycares.QUERY_CLASS_ANY,
}
class DNSResolver:
def __init__(
self,
nameservers: Sequence[str] | None = None,
loop: asyncio.AbstractEventLoop | None = None,
**kwargs: Any,
) -> None: # TODO(PY311): Use Unpack for kwargs.
self._closed = True
self.loop = loop or asyncio.get_event_loop()
if TYPE_CHECKING:
assert self.loop is not None
kwargs.pop('sock_state_cb', None)
timeout = kwargs.pop('timeout', None)
self._timeout = timeout
self._event_thread, self._channel = self._make_channel(**kwargs)
if nameservers:
self.nameservers = nameservers
self._read_fds: set[int] = set()
self._write_fds: set[int] = set()
self._timer: asyncio.TimerHandle | None = None
self._closed = False
def _make_channel(self, **kwargs: Any) -> tuple[bool, pycares.Channel]:
# pycares 5+ uses event_thread by default when sock_state_cb
# is not provided
try:
return True, pycares.Channel(timeout=self._timeout, **kwargs)
except pycares.AresError as e:
if sys.platform == 'linux':
_LOGGER.warning(
'Failed to create DNS resolver channel with automatic '
'monitoring of resolver configuration changes. This '
'usually means the system ran out of inotify watches. '
'Falling back to socket state callback. Consider '
'increasing the system inotify watch limit: %s',
e,
)
else:
_LOGGER.warning(
'Failed to create DNS resolver channel with automatic '
'monitoring of resolver configuration changes. '
'Falling back to socket state callback: %s',
e,
)
# Fall back to sock_state_cb (needs SelectorEventLoop on Windows)
if sys.platform == 'win32' and not isinstance(
self.loop, asyncio.SelectorEventLoop
):
try:
import winloop
if not isinstance(self.loop, winloop.Loop):
raise RuntimeError(WINDOWS_SELECTOR_ERR_MSG)
except ModuleNotFoundError as ex:
raise RuntimeError(WINDOWS_SELECTOR_ERR_MSG) from ex
# Use weak reference for deterministic cleanup. Without it there's a
# reference cycle (DNSResolver -> _channel -> callback -> DNSResolver).
# Python 3.4+ can handle cycles with __del__, but weak ref ensures
# cleanup happens immediately when last reference is dropped.
weak_self = weakref.ref(self)
def sock_state_cb_wrapper(
fd: int, readable: bool, writable: bool
) -> None:
this = weak_self()
if this is not None:
this._sock_state_cb(fd, readable, writable)
return False, pycares.Channel(
sock_state_cb=sock_state_cb_wrapper,
timeout=self._timeout,
**kwargs,
)
@property
def nameservers(self) -> Sequence[str]:
# pycares 5.x returns servers with port (e.g., '8.8.8.8:53')
# Strip port for backward compatibility with pycares 4.x
return [s.rsplit(':', 1)[0] for s in self._channel.servers]
@nameservers.setter
def nameservers(self, value: Iterable[str | bytes]) -> None:
self._channel.servers = value
def _callback(
self, fut: asyncio.Future[_T], result: _T, errorno: int | None
) -> None:
# The future can already be done if pycares raised synchronously
# and _capture_ares_error set the exception before c-ares delivered
# the same error through this callback.
if fut.done():
return
if errorno is not None:
fut.set_exception(
error.DNSError(errorno, pycares.errno.strerror(errorno))
)
else:
fut.set_result(result)
def _get_future_callback(
self,
) -> tuple[asyncio.Future[_T], Callable[[_T, int | None], None]]:
"""Return a future and a callback to set the result of the future."""
cb: Callable[[_T, int | None], None]
future: asyncio.Future[_T] = self.loop.create_future()
if self._event_thread:
cb = functools.partial( # type: ignore[assignment]
self.loop.call_soon_threadsafe,
self._callback, # type: ignore[arg-type]
future,
)
else:
cb = functools.partial(self._callback, future)
return future, cb
def _query_callback(
self,
fut: asyncio.Future[QueryResult],
qtype: int,
result: pycares.DNSResult,
errorno: int | None,
) -> None:
"""Callback for query that converts results to compatible format."""
# See _callback for why we guard on done() rather than cancelled().
if fut.done():
return
if errorno is not None:
fut.set_exception(
error.DNSError(errorno, pycares.errno.strerror(errorno))
)
return
try:
converted = convert_result(result, qtype)
except error.DNSError as exc:
fut.set_exception(exc)
else:
fut.set_result(converted)
def _get_query_future_callback(
self, qtype: int
) -> tuple[asyncio.Future[QueryResult], Callable[..., None]]:
"""Return a future and callback for query with result conversion."""
future: asyncio.Future[QueryResult] = self.loop.create_future()
cb: Callable[..., None]
if self._event_thread:
cb = functools.partial( # type: ignore[assignment]
self.loop.call_soon_threadsafe,
self._query_callback, # type: ignore[arg-type]
future,
qtype,
)
else:
cb = functools.partial(self._query_callback, future, qtype)
return future, cb
@contextlib.contextmanager
def _capture_ares_error(self, fut: asyncio.Future[_T]) -> Iterator[None]:
# When pycares raises synchronously (e.g. ARES_EBADNAME for a
# malformed hostname), c-ares may also invoke the callback first,
# leaving the future already done. Route the error through the
# future so callers can rely on `await` to raise.
try:
yield
except pycares.AresError as exc:
if fut.done():
return
# pycares always raises (errno, message), but be defensive:
# an args-less AresError should still resolve the future to
# avoid an indefinite hang on `await`.
errno = exc.args[0] if exc.args else error.ARES_EFORMERR
fut.set_exception(
error.DNSError(errno, pycares.errno.strerror(errno))
)
@overload
def query(
self, host: str, qtype: Literal['A'], qclass: str | None = ...
) -> asyncio.Future[list[AresQueryAResult]]: ...
@overload
def query(
self, host: str, qtype: Literal['AAAA'], qclass: str | None = ...
) -> asyncio.Future[list[AresQueryAAAAResult]]: ...
@overload
def query(
self, host: str, qtype: Literal['CAA'], qclass: str | None = ...
) -> asyncio.Future[list[AresQueryCAAResult]]: ...
@overload
def query(
self, host: str, qtype: Literal['CNAME'], qclass: str | None = ...
) -> asyncio.Future[AresQueryCNAMEResult]: ...
@overload
def query(
self, host: str, qtype: Literal['MX'], qclass: str | None = ...
) -> asyncio.Future[list[AresQueryMXResult]]: ...
@overload
def query(
self, host: str, qtype: Literal['NAPTR'], qclass: str | None = ...
) -> asyncio.Future[list[AresQueryNAPTRResult]]: ...
@overload
def query(
self, host: str, qtype: Literal['NS'], qclass: str | None = ...
) -> asyncio.Future[list[AresQueryNSResult]]: ...
@overload
def query(
self, host: str, qtype: Literal['PTR'], qclass: str | None = ...
) -> asyncio.Future[AresQueryPTRResult]: ...
@overload
def query(
self, host: str, qtype: Literal['SOA'], qclass: str | None = ...
) -> asyncio.Future[AresQuerySOAResult]: ...
@overload
def query(
self, host: str, qtype: Literal['SRV'], qclass: str | None = ...
) -> asyncio.Future[list[AresQuerySRVResult]]: ...
@overload
def query(
self, host: str, qtype: Literal['TXT'], qclass: str | None = ...
) -> asyncio.Future[list[AresQueryTXTResult]]: ...
def query(
self, host: str, qtype: str, qclass: str | None = None
) -> asyncio.Future[list[Any]] | asyncio.Future[Any]:
"""Query DNS records (deprecated, use query_dns instead)."""
warnings.warn(
'query() is deprecated, use query_dns() instead',
DeprecationWarning,
stacklevel=2,
)
try:
qtype_int = query_type_map[qtype]
except KeyError as e:
raise ValueError(f'invalid query type: {qtype}') from e
qclass_int: int | None = None
if qclass is not None:
try:
qclass_int = query_class_map[qclass]
except KeyError as e:
raise ValueError(f'invalid query class: {qclass}') from e
fut, cb = self._get_query_future_callback(qtype_int)
with self._capture_ares_error(fut):
if qclass_int is not None:
self._channel.query(
host, qtype_int, query_class=qclass_int, callback=cb
)
else:
self._channel.query(host, qtype_int, callback=cb)
return fut
def query_dns(
self, host: str, qtype: str, qclass: str | None = None
) -> asyncio.Future[pycares.DNSResult]:
"""Query DNS records, returning native pycares 5.x DNSResult."""
try:
qtype_int = query_type_map[qtype]
except KeyError as e:
raise ValueError(f'invalid query type: {qtype}') from e
qclass_int: int | None = None
if qclass is not None:
try:
qclass_int = query_class_map[qclass]
except KeyError as e:
raise ValueError(f'invalid query class: {qclass}') from e
fut: asyncio.Future[pycares.DNSResult]
fut, cb = self._get_future_callback()
with self._capture_ares_error(fut):
if qclass_int is not None:
self._channel.query(
host, qtype_int, query_class=qclass_int, callback=cb
)
else:
self._channel.query(host, qtype_int, callback=cb)
return fut
def _gethostbyname_callback(
self,
fut: asyncio.Future[AresHostResult],
host: str,
result: pycares.AddrInfoResult | None,
errorno: int | None,
) -> None:
"""Callback for gethostbyname that converts AddrInfoResult."""
# See _callback for why we guard on done() rather than cancelled().
if fut.done():
return
if errorno is not None:
fut.set_exception(
error.DNSError(errorno, pycares.errno.strerror(errorno))
)
else:
assert result is not None # noqa: S101
# node.addr is (address_bytes, port) - extract and decode
addresses = [node.addr[0].decode() for node in result.nodes]
# Get canonical name from cnames if available
name = result.cnames[0].name if result.cnames else host
fut.set_result(
AresHostResult(name=name, aliases=[], addresses=addresses)
)
def gethostbyname(
self, host: str, family: socket.AddressFamily
) -> asyncio.Future[AresHostResult]:
"""
Resolve hostname to addresses.
Deprecated: Use getaddrinfo() instead. This is implemented using
getaddrinfo as pycares 5.x removed the gethostbyname method.
"""
warnings.warn(
'gethostbyname() is deprecated, use getaddrinfo() instead',
DeprecationWarning,
stacklevel=2,
)
fut: asyncio.Future[AresHostResult] = self.loop.create_future()
cb: Callable[..., None]
if self._event_thread:
cb = functools.partial( # type: ignore[assignment]
self.loop.call_soon_threadsafe,
self._gethostbyname_callback, # type: ignore[arg-type]
fut,
host,
)
else:
cb = functools.partial(self._gethostbyname_callback, fut, host)
with self._capture_ares_error(fut):
self._channel.getaddrinfo(host, None, family=family, callback=cb)
return fut
def getaddrinfo(
self,
host: str,
family: socket.AddressFamily = socket.AF_UNSPEC,
port: int | None = None,
proto: int = 0,
type: int = 0,
flags: int = 0,
) -> asyncio.Future[pycares.AddrInfoResult]:
fut: asyncio.Future[pycares.AddrInfoResult]
fut, cb = self._get_future_callback()
with self._capture_ares_error(fut):
self._channel.getaddrinfo(
host,
port,
family=family,
type=type,
proto=proto,
flags=flags,
callback=cb,
)
return fut
def getnameinfo(
self,
sockaddr: tuple[str, int] | tuple[str, int, int, int],
flags: int = 0,
) -> asyncio.Future[pycares.NameInfoResult]:
fut: asyncio.Future[pycares.NameInfoResult]
fut, cb = self._get_future_callback()
with self._capture_ares_error(fut):
self._channel.getnameinfo(sockaddr, flags, callback=cb)
return fut
def gethostbyaddr(self, name: str) -> asyncio.Future[pycares.HostResult]:
fut: asyncio.Future[pycares.HostResult]
fut, cb = self._get_future_callback()
with self._capture_ares_error(fut):
self._channel.gethostbyaddr(name, callback=cb)
return fut
def cancel(self) -> None:
self._channel.cancel()
def _sock_state_cb(self, fd: int, readable: bool, writable: bool) -> None:
if readable or writable:
if readable:
self.loop.add_reader(
fd, self._channel.process_fd, fd, pycares.ARES_SOCKET_BAD
)
self._read_fds.add(fd)
if writable:
self.loop.add_writer(
fd, self._channel.process_fd, pycares.ARES_SOCKET_BAD, fd
)
self._write_fds.add(fd)
if self._timer is None:
self._start_timer()
else:
# socket is now closed
if fd in self._read_fds:
self._read_fds.discard(fd)
self.loop.remove_reader(fd)
if fd in self._write_fds:
self._write_fds.discard(fd)
self.loop.remove_writer(fd)
if (
not self._read_fds
and not self._write_fds
and self._timer is not None
):
self._timer.cancel()
self._timer = None
def _timer_cb(self) -> None:
if self._read_fds or self._write_fds:
self._channel.process_fd(
pycares.ARES_SOCKET_BAD, pycares.ARES_SOCKET_BAD
)
self._start_timer()
else:
self._timer = None
def _start_timer(self) -> None:
timeout = self._timeout
if timeout is None or timeout < 0 or timeout > 1:
timeout = 1
elif timeout == 0:
timeout = 0.1
self._timer = self.loop.call_later(timeout, self._timer_cb)
def _cleanup(self) -> None:
"""Cleanup timers and file descriptors when closing resolver."""
if self._closed:
return
# Mark as closed first to prevent double cleanup
self._closed = True
# Cancel timer if running
if self._timer is not None:
self._timer.cancel()
self._timer = None
# Remove all file descriptors
for fd in self._read_fds:
self.loop.remove_reader(fd)
for fd in self._write_fds:
self.loop.remove_writer(fd)
self._read_fds.clear()
self._write_fds.clear()
self._channel.close()
async def close(self) -> None:
"""
Cleanly close the DNS resolver.
This should be called to ensure all resources are properly released.
After calling close(), the resolver should not be used again.
"""
if not self._closed:
self._channel.cancel()
self._cleanup()
async def __aenter__(self) -> DNSResolver:
"""Enter the async context manager."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: TracebackType | None,
) -> None:
"""Exit the async context manager."""
await self.close()
def __del__(self) -> None:
"""Handle cleanup when the resolver is garbage collected."""
# Check if we have a channel to clean up
# This can happen if an exception occurs during __init__ before
# _channel is created (e.g., RuntimeError on Windows
# without proper loop)
if hasattr(self, '_channel'):
self._cleanup()

View File

@ -0,0 +1,288 @@
"""
Compatibility layer for pycares 5.x API.
This module provides result types compatible with pycares 4.x API
to maintain backward compatibility with existing code.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Union, cast
import pycares
from . import error
_SINGLE_RESULT_QTYPES = frozenset(
{
pycares.QUERY_TYPE_CNAME,
pycares.QUERY_TYPE_SOA,
pycares.QUERY_TYPE_PTR,
}
)
def _maybe_str(data: bytes) -> str | bytes:
"""Decode bytes as ASCII, return bytes if decode fails (pycares 4.x)."""
try:
return data.decode('ascii')
except UnicodeDecodeError:
return data
@dataclass(frozen=True, slots=True)
class AresQueryAResult:
"""A record result (compatible with pycares 4.x ares_query_a_result)."""
host: str
ttl: int
@dataclass(frozen=True, slots=True)
class AresQueryAAAAResult:
"""AAAA record result (pycares 4.x compat)."""
host: str
ttl: int
@dataclass(frozen=True, slots=True)
class AresQueryCNAMEResult:
"""CNAME record result (pycares 4.x compat)."""
cname: str
ttl: int
@dataclass(frozen=True, slots=True)
class AresQueryMXResult:
"""MX record result (pycares 4.x compat)."""
host: str
priority: int
ttl: int
@dataclass(frozen=True, slots=True)
class AresQueryNSResult:
"""NS record result (pycares 4.x compat)."""
host: str
ttl: int
@dataclass(frozen=True, slots=True)
class AresQueryTXTResult:
"""TXT record result (pycares 4.x compat)."""
text: str | bytes # str if ASCII, bytes otherwise (pycares 4.x behavior)
ttl: int
@dataclass(frozen=True, slots=True)
class AresQuerySOAResult:
"""SOA record result (pycares 4.x compat)."""
nsname: str
hostmaster: str
serial: int
refresh: int
retry: int
expires: int
minttl: int
ttl: int
@dataclass(frozen=True, slots=True)
class AresQuerySRVResult:
"""SRV record result (pycares 4.x compat)."""
host: str
port: int
priority: int
weight: int
ttl: int
@dataclass(frozen=True, slots=True)
class AresQueryNAPTRResult:
"""NAPTR record result (pycares 4.x compat)."""
order: int
preference: int
flags: str
service: str
regex: str
replacement: str
ttl: int
@dataclass(frozen=True, slots=True)
class AresQueryCAAResult:
"""CAA record result (pycares 4.x compat)."""
critical: int
property: str
value: str
ttl: int
@dataclass(frozen=True, slots=True)
class AresQueryPTRResult:
"""PTR record result (pycares 4.x compat)."""
name: str
ttl: int
aliases: list[str]
@dataclass(frozen=True, slots=True)
class AresHostResult:
"""Host result (compatible with pycares 4.x ares_host_result)."""
name: str
aliases: list[str]
addresses: list[str]
# Type alias for a single converted record
ConvertedRecord = Union[
AresQueryAResult,
AresQueryAAAAResult,
AresQueryCNAMEResult,
AresQueryMXResult,
AresQueryNSResult,
AresQueryTXTResult,
AresQuerySOAResult,
AresQuerySRVResult,
AresQueryNAPTRResult,
AresQueryCAAResult,
AresQueryPTRResult,
pycares.DNSRecord, # Unknown types returned as-is
]
# Type alias for query results
QueryResult = Union[
list[AresQueryAResult],
list[AresQueryAAAAResult],
AresQueryCNAMEResult,
list[AresQueryMXResult],
list[AresQueryNSResult],
list[AresQueryTXTResult],
AresQuerySOAResult,
list[AresQuerySRVResult],
list[AresQueryNAPTRResult],
list[AresQueryCAAResult],
AresQueryPTRResult,
list[ConvertedRecord], # For ANY query type
]
def _convert_record(record: pycares.DNSRecord) -> ConvertedRecord:
"""Convert a single DNS record to pycares 4.x compatible format."""
ttl = record.ttl
record_type = record.type
if record_type == pycares.QUERY_TYPE_A:
a_data = cast(pycares.ARecordData, record.data)
return AresQueryAResult(host=a_data.addr, ttl=ttl)
if record_type == pycares.QUERY_TYPE_AAAA:
aaaa_data = cast(pycares.AAAARecordData, record.data)
return AresQueryAAAAResult(host=aaaa_data.addr, ttl=ttl)
if record_type == pycares.QUERY_TYPE_CNAME:
cname_data = cast(pycares.CNAMERecordData, record.data)
return AresQueryCNAMEResult(cname=cname_data.cname, ttl=ttl)
if record_type == pycares.QUERY_TYPE_MX:
mx_data = cast(pycares.MXRecordData, record.data)
return AresQueryMXResult(
host=mx_data.exchange, priority=mx_data.priority, ttl=ttl
)
if record_type == pycares.QUERY_TYPE_NS:
ns_data = cast(pycares.NSRecordData, record.data)
return AresQueryNSResult(host=ns_data.nsdname, ttl=ttl)
if record_type == pycares.QUERY_TYPE_TXT:
txt_data = cast(pycares.TXTRecordData, record.data)
return AresQueryTXTResult(text=_maybe_str(txt_data.data), ttl=ttl)
if record_type == pycares.QUERY_TYPE_SOA:
soa_data = cast(pycares.SOARecordData, record.data)
return AresQuerySOAResult(
nsname=soa_data.mname,
hostmaster=soa_data.rname,
serial=soa_data.serial,
refresh=soa_data.refresh,
retry=soa_data.retry,
expires=soa_data.expire,
minttl=soa_data.minimum,
ttl=ttl,
)
if record_type == pycares.QUERY_TYPE_SRV:
srv_data = cast(pycares.SRVRecordData, record.data)
return AresQuerySRVResult(
host=srv_data.target,
port=srv_data.port,
priority=srv_data.priority,
weight=srv_data.weight,
ttl=ttl,
)
if record_type == pycares.QUERY_TYPE_NAPTR:
naptr_data = cast(pycares.NAPTRRecordData, record.data)
return AresQueryNAPTRResult(
order=naptr_data.order,
preference=naptr_data.preference,
flags=naptr_data.flags,
service=naptr_data.service,
regex=naptr_data.regexp,
replacement=naptr_data.replacement,
ttl=ttl,
)
if record_type == pycares.QUERY_TYPE_CAA:
caa_data = cast(pycares.CAARecordData, record.data)
return AresQueryCAAResult(
critical=caa_data.critical,
property=caa_data.tag,
value=caa_data.value,
ttl=ttl,
)
if record_type == pycares.QUERY_TYPE_PTR:
ptr_data = cast(pycares.PTRRecordData, record.data)
return AresQueryPTRResult(name=ptr_data.dname, ttl=ttl, aliases=[])
# Return raw record for unknown types
return record
def convert_result(dns_result: pycares.DNSResult, qtype: int) -> QueryResult:
"""Convert pycares 5.x DNSResult to pycares 4.x compatible format."""
# For ANY - convert all records and return mixed list
if qtype == pycares.QUERY_TYPE_ANY:
return [_convert_record(record) for record in dns_result.answer]
results: list[ConvertedRecord] = []
for record in dns_result.answer:
record_type = record.type
# Filter by query type since answer can contain other types
# (e.g., CNAME records when querying for A/AAAA)
if record_type != qtype:
continue
converted = _convert_record(record)
# CNAME, SOA, and PTR return single result, not list
if record_type in _SINGLE_RESULT_QTYPES:
return cast(QueryResult, converted)
results.append(converted)
# NOERROR/NODATA: c-ares delivered ARES_SUCCESS but the answer has no
# records of the queried type. pycares 4.x raised ARES_ENODATA here;
# without this branch single-result qtypes (CNAME/SOA/PTR) would
# resolve to [] and crash callers reading .name/.cname/.nsname.
if not results:
raise error.DNSError(
pycares.errno.ARES_ENODATA,
pycares.errno.strerror(pycares.errno.ARES_ENODATA),
)
return results

View File

@ -0,0 +1,62 @@
from pycares.errno import (
ARES_EADDRGETNETWORKPARAMS,
ARES_EBADFAMILY,
ARES_EBADFLAGS,
ARES_EBADHINTS,
ARES_EBADNAME,
ARES_EBADQUERY,
ARES_EBADRESP,
ARES_EBADSTR,
ARES_ECANCELLED,
ARES_ECONNREFUSED,
ARES_EDESTRUCTION,
ARES_EFILE,
ARES_EFORMERR,
ARES_ELOADIPHLPAPI,
ARES_ENODATA,
ARES_ENOMEM,
ARES_ENONAME,
ARES_ENOTFOUND,
ARES_ENOTIMP,
ARES_ENOTINITIALIZED,
ARES_EOF,
ARES_EREFUSED,
ARES_ESERVFAIL,
ARES_ESERVICE,
ARES_ETIMEOUT,
ARES_SUCCESS,
)
__all__ = [
'ARES_EADDRGETNETWORKPARAMS',
'ARES_EBADFAMILY',
'ARES_EBADFLAGS',
'ARES_EBADHINTS',
'ARES_EBADNAME',
'ARES_EBADQUERY',
'ARES_EBADRESP',
'ARES_EBADSTR',
'ARES_ECANCELLED',
'ARES_ECONNREFUSED',
'ARES_EDESTRUCTION',
'ARES_EFILE',
'ARES_EFORMERR',
'ARES_ELOADIPHLPAPI',
'ARES_ENODATA',
'ARES_ENOMEM',
'ARES_ENONAME',
'ARES_ENOTFOUND',
'ARES_ENOTIMP',
'ARES_ENOTINITIALIZED',
'ARES_EOF',
'ARES_EREFUSED',
'ARES_ESERVFAIL',
'ARES_ESERVICE',
'ARES_ETIMEOUT',
'ARES_SUCCESS',
'DNSError',
]
class DNSError(Exception):
"""Base class for all DNS errors."""

View File

@ -0,0 +1,123 @@
Metadata-Version: 2.4
Name: aiohappyeyeballs
Version: 2.6.2
Summary: Happy Eyeballs for asyncio
License: PSF-2.0
License-File: LICENSE
Author: J. Nick Koston
Author-email: nick@koston.org
Requires-Python: >=3.10
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Natural Language :: English
Classifier: Operating System :: OS Independent
Classifier: Topic :: Software Development :: Libraries
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: License :: OSI Approved :: Python Software Foundation License
Project-URL: Bug Tracker, https://github.com/aio-libs/aiohappyeyeballs/issues
Project-URL: Changelog, https://github.com/aio-libs/aiohappyeyeballs/blob/main/CHANGELOG.md
Project-URL: Documentation, https://aiohappyeyeballs.readthedocs.io
Project-URL: Repository, https://github.com/aio-libs/aiohappyeyeballs
Description-Content-Type: text/markdown
# aiohappyeyeballs
<p align="center">
<a href="https://github.com/aio-libs/aiohappyeyeballs/actions/workflows/ci.yml?query=branch%3Amain">
<img src="https://img.shields.io/github/actions/workflow/status/aio-libs/aiohappyeyeballs/ci-cd.yml?branch=main&label=CI&logo=github&style=flat-square" alt="CI Status" >
</a>
<a href="https://aiohappyeyeballs.readthedocs.io">
<img src="https://img.shields.io/readthedocs/aiohappyeyeballs.svg?logo=read-the-docs&logoColor=fff&style=flat-square" alt="Documentation Status">
</a>
<a href="https://codecov.io/gh/aio-libs/aiohappyeyeballs">
<img src="https://img.shields.io/codecov/c/github/aio-libs/aiohappyeyeballs.svg?logo=codecov&logoColor=fff&style=flat-square" alt="Test coverage percentage">
</a>
</p>
<p align="center">
<a href="https://python-poetry.org/">
<img src="https://img.shields.io/badge/packaging-poetry-299bd7?style=flat-square&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAASCAYAAABrXO8xAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAJJSURBVHgBfZLPa1NBEMe/s7tNXoxW1KJQKaUHkXhQvHgW6UHQQ09CBS/6V3hKc/AP8CqCrUcpmop3Cx48eDB4yEECjVQrlZb80CRN8t6OM/teagVxYZi38+Yz853dJbzoMV3MM8cJUcLMSUKIE8AzQ2PieZzFxEJOHMOgMQQ+dUgSAckNXhapU/NMhDSWLs1B24A8sO1xrN4NECkcAC9ASkiIJc6k5TRiUDPhnyMMdhKc+Zx19l6SgyeW76BEONY9exVQMzKExGKwwPsCzza7KGSSWRWEQhyEaDXp6ZHEr416ygbiKYOd7TEWvvcQIeusHYMJGhTwF9y7sGnSwaWyFAiyoxzqW0PM/RjghPxF2pWReAowTEXnDh0xgcLs8l2YQmOrj3N7ByiqEoH0cARs4u78WgAVkoEDIDoOi3AkcLOHU60RIg5wC4ZuTC7FaHKQm8Hq1fQuSOBvX/sodmNJSB5geaF5CPIkUeecdMxieoRO5jz9bheL6/tXjrwCyX/UYBUcjCaWHljx1xiX6z9xEjkYAzbGVnB8pvLmyXm9ep+W8CmsSHQQY77Zx1zboxAV0w7ybMhQmfqdmmw3nEp1I0Z+FGO6M8LZdoyZnuzzBdjISicKRnpxzI9fPb+0oYXsNdyi+d3h9bm9MWYHFtPeIZfLwzmFDKy1ai3p+PDls1Llz4yyFpferxjnyjJDSEy9CaCx5m2cJPerq6Xm34eTrZt3PqxYO1XOwDYZrFlH1fWnpU38Y9HRze3lj0vOujZcXKuuXm3jP+s3KbZVra7y2EAAAAAASUVORK5CYII=" alt="Poetry">
</a>
<a href="https://github.com/astral-sh/ruff">
<img src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json" alt="Ruff">
</a>
<a href="https://github.com/pre-commit/pre-commit">
<img src="https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=flat-square" alt="pre-commit">
</a>
</p>
<p align="center">
<a href="https://pypi.org/project/aiohappyeyeballs/">
<img src="https://img.shields.io/pypi/v/aiohappyeyeballs.svg?logo=python&logoColor=fff&style=flat-square" alt="PyPI Version">
</a>
<img src="https://img.shields.io/pypi/pyversions/aiohappyeyeballs.svg?style=flat-square&logo=python&amp;logoColor=fff" alt="Supported Python versions">
<img src="https://img.shields.io/pypi/l/aiohappyeyeballs.svg?style=flat-square" alt="License">
</p>
---
**Documentation**: <a href="https://aiohappyeyeballs.readthedocs.io" target="_blank">https://aiohappyeyeballs.readthedocs.io </a>
**Source Code**: <a href="https://github.com/aio-libs/aiohappyeyeballs" target="_blank">https://github.com/aio-libs/aiohappyeyeballs </a>
---
[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs)
([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html))
## Use case
This library exists to allow connecting with
[Happy Eyeballs](https://en.wikipedia.org/wiki/Happy_Eyeballs)
([RFC 8305](https://www.rfc-editor.org/rfc/rfc8305.html))
when you
already have a list of addrinfo and not a DNS name.
The stdlib version of `loop.create_connection()`
will only work when you pass in an unresolved name which
is not a good fit when using DNS caching or resolving
names via another method such as `zeroconf`.
## Installation
Install this via pip (or your favourite package manager):
`pip install aiohappyeyeballs`
## License
[aiohappyeyeballs is licensed under the same terms as cpython itself.](https://github.com/python/cpython/blob/main/LICENSE)
## Example usage
```python
addr_infos = await loop.getaddrinfo("example.org", 80)
socket = await start_connection(addr_infos)
socket = await start_connection(addr_infos, local_addr_infos=local_addr_infos, happy_eyeballs_delay=0.2)
transport, protocol = await loop.create_connection(
MyProtocol, sock=socket, ...)
# Remove the first address for each family from addr_info
pop_addr_infos_interleave(addr_info, 1)
# Remove all matching address from addr_info
remove_addr_infos(addr_info, "dead::beef::")
# Convert a local_addr to local_addr_infos
local_addr_infos = addr_to_addr_infos(("127.0.0.1",0))
```
## Credits
This package contains code from cpython and is licensed under the same terms as cpython itself.
This package was created with
[Copier](https://copier.readthedocs.io/) and the
[browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template)
project template.

View File

@ -0,0 +1,16 @@
aiohappyeyeballs-2.6.2.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
aiohappyeyeballs-2.6.2.dist-info/METADATA,sha256=cqs2VY8TwE2e_4qqnW409Si3suvj-aM-8dCiWG2Angk,5888
aiohappyeyeballs-2.6.2.dist-info/RECORD,,
aiohappyeyeballs-2.6.2.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
aiohappyeyeballs-2.6.2.dist-info/licenses/LICENSE,sha256=Oy-B_iHRgcSZxZolbI4ZaEVdZonSaaqFNzv7avQdo78,13936
aiohappyeyeballs/__init__.py,sha256=Af9ADZj3BWLfGaA7ITOFPlKIenh3ozSNWL3yezSZ2Jw,361
aiohappyeyeballs/__pycache__/__init__.cpython-312.pyc,,
aiohappyeyeballs/__pycache__/_staggered.cpython-312.pyc,,
aiohappyeyeballs/__pycache__/impl.cpython-312.pyc,,
aiohappyeyeballs/__pycache__/types.cpython-312.pyc,,
aiohappyeyeballs/__pycache__/utils.cpython-312.pyc,,
aiohappyeyeballs/_staggered.py,sha256=aj3cSwHEDX88UMfO9bUau9tfrRAszhjg99dpEMiAOGM,6698
aiohappyeyeballs/impl.py,sha256=TIkAK4xfACvKBp1s7DAKSobHefUTOC2HGE-n0tOthRk,9667
aiohappyeyeballs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
aiohappyeyeballs/types.py,sha256=_8JmHFix6MeM1e7hRP7BleEaGy93GswGtzQv068zKY8,288
aiohappyeyeballs/utils.py,sha256=dPAcNcrU_VhaTolTEEA94hgh9ONDN9_dYT4Xo9ORqQw,2922

View File

@ -0,0 +1,4 @@
Wheel-Version: 1.0
Generator: poetry-core 2.4.0
Root-Is-Purelib: true
Tag: py3-none-any

View File

@ -0,0 +1,279 @@
A. HISTORY OF THE SOFTWARE
==========================
Python was created in the early 1990s by Guido van Rossum at Stichting
Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands
as a successor of a language called ABC. Guido remains Python's
principal author, although it includes many contributions from others.
In 1995, Guido continued his work on Python at the Corporation for
National Research Initiatives (CNRI, see https://www.cnri.reston.va.us)
in Reston, Virginia where he released several versions of the
software.
In May 2000, Guido and the Python core development team moved to
BeOpen.com to form the BeOpen PythonLabs team. In October of the same
year, the PythonLabs team moved to Digital Creations, which became
Zope Corporation. In 2001, the Python Software Foundation (PSF, see
https://www.python.org/psf/) was formed, a non-profit organization
created specifically to own Python-related Intellectual Property.
Zope Corporation was a sponsoring member of the PSF.
All Python releases are Open Source (see https://opensource.org for
the Open Source Definition). Historically, most, but not all, Python
releases have also been GPL-compatible; the table below summarizes
the various releases.
Release Derived Year Owner GPL-
from compatible? (1)
0.9.0 thru 1.2 1991-1995 CWI yes
1.3 thru 1.5.2 1.2 1995-1999 CNRI yes
1.6 1.5.2 2000 CNRI no
2.0 1.6 2000 BeOpen.com no
1.6.1 1.6 2001 CNRI yes (2)
2.1 2.0+1.6.1 2001 PSF no
2.0.1 2.0+1.6.1 2001 PSF yes
2.1.1 2.1+2.0.1 2001 PSF yes
2.1.2 2.1.1 2002 PSF yes
2.1.3 2.1.2 2002 PSF yes
2.2 and above 2.1.1 2001-now PSF yes
Footnotes:
(1) GPL-compatible doesn't mean that we're distributing Python under
the GPL. All Python licenses, unlike the GPL, let you distribute
a modified version without making your changes open source. The
GPL-compatible licenses make it possible to combine Python with
other software that is released under the GPL; the others don't.
(2) According to Richard Stallman, 1.6.1 is not GPL-compatible,
because its license has a choice of law clause. According to
CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1
is "not incompatible" with the GPL.
Thanks to the many outside volunteers who have worked under Guido's
direction to make these releases possible.
B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON
===============================================================
Python software and documentation are licensed under the
Python Software Foundation License Version 2.
Starting with Python 3.8.6, examples, recipes, and other code in
the documentation are dual licensed under the PSF License Version 2
and the Zero-Clause BSD license.
Some software incorporated into Python is under different licenses.
The licenses are listed with code falling under that license.
PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2
--------------------------------------------
1. This LICENSE AGREEMENT is between the Python Software Foundation
("PSF"), and the Individual or Organization ("Licensee") accessing and
otherwise using this software ("Python") in source or binary form and
its associated documentation.
2. Subject to the terms and conditions of this License Agreement, PSF hereby
grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce,
analyze, test, perform and/or display publicly, prepare derivative works,
distribute, and otherwise use Python alone or in any derivative version,
provided, however, that PSF's License Agreement and PSF's notice of copyright,
i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010,
2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation;
All Rights Reserved" are retained in Python alone or in any derivative version
prepared by Licensee.
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python.
4. PSF is making Python available to Licensee on an "AS IS"
basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. Nothing in this License Agreement shall be deemed to create any
relationship of agency, partnership, or joint venture between PSF and
Licensee. This License Agreement does not grant permission to use PSF
trademarks or trade name in a trademark sense to endorse or promote
products or services of Licensee, or any third party.
8. By copying, installing or otherwise using Python, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0
-------------------------------------------
BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1
1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an
office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the
Individual or Organization ("Licensee") accessing and otherwise using
this software in source or binary form and its associated
documentation ("the Software").
2. Subject to the terms and conditions of this BeOpen Python License
Agreement, BeOpen hereby grants Licensee a non-exclusive,
royalty-free, world-wide license to reproduce, analyze, test, perform
and/or display publicly, prepare derivative works, distribute, and
otherwise use the Software alone or in any derivative version,
provided, however, that the BeOpen Python License is retained in the
Software, alone or in any derivative version prepared by Licensee.
3. BeOpen is making the Software available to Licensee on an "AS IS"
basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE
SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS
AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY
DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
5. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
6. This License Agreement shall be governed by and interpreted in all
respects by the law of the State of California, excluding conflict of
law provisions. Nothing in this License Agreement shall be deemed to
create any relationship of agency, partnership, or joint venture
between BeOpen and Licensee. This License Agreement does not grant
permission to use BeOpen trademarks or trade names in a trademark
sense to endorse or promote products or services of Licensee, or any
third party. As an exception, the "BeOpen Python" logos available at
http://www.pythonlabs.com/logos.html may be used according to the
permissions granted on that web page.
7. By copying, installing or otherwise using the software, Licensee
agrees to be bound by the terms and conditions of this License
Agreement.
CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1
---------------------------------------
1. This LICENSE AGREEMENT is between the Corporation for National
Research Initiatives, having an office at 1895 Preston White Drive,
Reston, VA 20191 ("CNRI"), and the Individual or Organization
("Licensee") accessing and otherwise using Python 1.6.1 software in
source or binary form and its associated documentation.
2. Subject to the terms and conditions of this License Agreement, CNRI
hereby grants Licensee a nonexclusive, royalty-free, world-wide
license to reproduce, analyze, test, perform and/or display publicly,
prepare derivative works, distribute, and otherwise use Python 1.6.1
alone or in any derivative version, provided, however, that CNRI's
License Agreement and CNRI's notice of copyright, i.e., "Copyright (c)
1995-2001 Corporation for National Research Initiatives; All Rights
Reserved" are retained in Python 1.6.1 alone or in any derivative
version prepared by Licensee. Alternately, in lieu of CNRI's License
Agreement, Licensee may substitute the following text (omitting the
quotes): "Python 1.6.1 is made available subject to the terms and
conditions in CNRI's License Agreement. This Agreement together with
Python 1.6.1 may be located on the internet using the following
unique, persistent identifier (known as a handle): 1895.22/1013. This
Agreement may also be obtained from a proxy server on the internet
using the following URL: http://hdl.handle.net/1895.22/1013".
3. In the event Licensee prepares a derivative work that is based on
or incorporates Python 1.6.1 or any part thereof, and wants to make
the derivative work available to others as provided herein, then
Licensee hereby agrees to include in any such work a brief summary of
the changes made to Python 1.6.1.
4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS"
basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND
DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT
INFRINGE ANY THIRD PARTY RIGHTS.
5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1,
OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
6. This License Agreement will automatically terminate upon a material
breach of its terms and conditions.
7. This License Agreement shall be governed by the federal
intellectual property law of the United States, including without
limitation the federal copyright law, and, to the extent such
U.S. federal law does not apply, by the law of the Commonwealth of
Virginia, excluding Virginia's conflict of law provisions.
Notwithstanding the foregoing, with regard to derivative works based
on Python 1.6.1 that incorporate non-separable material that was
previously distributed under the GNU General Public License (GPL), the
law of the Commonwealth of Virginia shall govern this License
Agreement only as to issues arising under or with respect to
Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this
License Agreement shall be deemed to create any relationship of
agency, partnership, or joint venture between CNRI and Licensee. This
License Agreement does not grant permission to use CNRI trademarks or
trade name in a trademark sense to endorse or promote products or
services of Licensee, or any third party.
8. By clicking on the "ACCEPT" button where indicated, or by copying,
installing or otherwise using Python 1.6.1, Licensee agrees to be
bound by the terms and conditions of this License Agreement.
ACCEPT
CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2
--------------------------------------------------
Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam,
The Netherlands. All rights reserved.
Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted,
provided that the above copyright notice appear in all copies and that
both that copyright notice and this permission notice appear in
supporting documentation, and that the name of Stichting Mathematisch
Centrum or CWI not be used in advertising or publicity pertaining to
distribution of the software without specific, written prior
permission.
STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO
THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE
FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION
----------------------------------------------------------------------
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.

View File

@ -0,0 +1,14 @@
__version__ = "2.6.2"
from .impl import start_connection
from .types import AddrInfoType, SocketFactoryType
from .utils import addr_to_addr_infos, pop_addr_infos_interleave, remove_addr_infos
__all__ = (
"AddrInfoType",
"SocketFactoryType",
"addr_to_addr_infos",
"pop_addr_infos_interleave",
"remove_addr_infos",
"start_connection",
)

View File

@ -0,0 +1,197 @@
import asyncio
import contextlib
from collections.abc import Awaitable, Callable, Iterable
from typing import (
TYPE_CHECKING,
Any,
TypeVar,
)
_T = TypeVar("_T")
RE_RAISE_EXCEPTIONS = (SystemExit, KeyboardInterrupt)
def _set_result(wait_next: "asyncio.Future[None]") -> None:
"""Set the result of a future if it is not already done."""
if not wait_next.done():
wait_next.set_result(None)
async def _wait_one(
futures: "Iterable[asyncio.Future[Any]]",
loop: asyncio.AbstractEventLoop,
) -> _T:
"""Wait for the first future to complete."""
wait_next = loop.create_future()
def _on_completion(fut: "asyncio.Future[Any]") -> None:
if not wait_next.done():
wait_next.set_result(fut)
for f in futures:
f.add_done_callback(_on_completion)
try:
return await wait_next
finally:
for f in futures:
f.remove_done_callback(_on_completion)
async def staggered_race(
coro_fns: Iterable[Callable[[], Awaitable[_T]]],
delay: float | None,
*,
loop: asyncio.AbstractEventLoop | None = None,
) -> tuple[_T | None, int | None, list[BaseException | None]]:
"""
Run coroutines with staggered start times and take the first to finish.
This method takes an iterable of coroutine functions. The first one is
started immediately. From then on, whenever the immediately preceding one
fails (raises an exception), or when *delay* seconds has passed, the next
coroutine is started. This continues until one of the coroutines complete
successfully, in which case all others are cancelled, or until all
coroutines fail.
The coroutines provided should be well-behaved in the following way:
* They should only ``return`` if completed successfully.
* They should always raise an exception if they did not complete
successfully. In particular, if they handle cancellation, they should
probably reraise, like this::
try:
# do work
except asyncio.CancelledError:
# undo partially completed work
raise
Args:
----
coro_fns: an iterable of coroutine functions, i.e. callables that
return a coroutine object when called. Use ``functools.partial`` or
lambdas to pass arguments.
delay: amount of time, in seconds, between starting coroutines. If
``None``, the coroutines will run sequentially.
loop: the event loop to use. If ``None``, the running loop is used.
Returns:
-------
tuple *(winner_result, winner_index, exceptions)* where
- *winner_result*: the result of the winning coroutine, or ``None``
if no coroutines won.
- *winner_index*: the index of the winning coroutine in
``coro_fns``, or ``None`` if no coroutines won. If the winning
coroutine may return None on success, *winner_index* can be used
to definitively determine whether any coroutine won.
- *exceptions*: list of exceptions returned by the coroutines.
``len(exceptions)`` is equal to the number of coroutines actually
started, and the order is the same as in ``coro_fns``. The winning
coroutine's entry is ``None``.
"""
loop = loop or asyncio.get_running_loop()
exceptions: list[BaseException | None] = []
tasks: set[asyncio.Task[tuple[_T, int] | None]] = set()
async def run_one_coro(
coro_fn: Callable[[], Awaitable[_T]],
this_index: int,
start_next: "asyncio.Future[None]",
) -> tuple[_T, int] | None:
"""
Run a single coroutine.
If the coroutine fails, set the exception in the exceptions list and
start the next coroutine by setting the result of the start_next.
If the coroutine succeeds, return the result and the index of the
coroutine in the coro_fns list.
If SystemExit or KeyboardInterrupt is raised, re-raise it.
"""
try:
result = await coro_fn()
except RE_RAISE_EXCEPTIONS:
raise
except BaseException as e:
exceptions[this_index] = e
_set_result(start_next) # Kickstart the next coroutine
return None
return result, this_index
start_next_timer: asyncio.TimerHandle | None = None
start_next: asyncio.Future[None] | None
task: asyncio.Task[tuple[_T, int] | None]
done: asyncio.Future[None] | asyncio.Task[tuple[_T, int] | None]
coro_iter = iter(coro_fns)
this_index = -1
try:
while True:
if coro_fn := next(coro_iter, None):
this_index += 1
exceptions.append(None)
start_next = loop.create_future()
task = loop.create_task(run_one_coro(coro_fn, this_index, start_next))
tasks.add(task)
start_next_timer = (
loop.call_later(delay, _set_result, start_next) if delay else None
)
elif not tasks:
# We exhausted the coro_fns list and no tasks are running
# so we have no winner and all coroutines failed.
break
while tasks or start_next:
done = await _wait_one(
(*tasks, start_next) if start_next else tasks, loop
)
if done is start_next:
# The current task has failed or the timer has expired
# so we need to start the next task.
start_next = None
if start_next_timer:
start_next_timer.cancel()
start_next_timer = None
# Break out of the task waiting loop to start the next
# task.
break
if TYPE_CHECKING:
assert isinstance(done, asyncio.Task)
tasks.remove(done)
if winner := done.result():
return *winner, exceptions
finally:
# We either have:
# - a winner
# - all tasks failed
# - a KeyboardInterrupt or SystemExit.
#
# If the timer is still running, cancel it.
#
if start_next_timer:
start_next_timer.cancel()
#
# If there are any tasks left, cancel them and than
# wait them so they fill the exceptions list.
#
for task in tasks:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
return None, None, exceptions

View File

@ -0,0 +1,261 @@
"""Base implementation."""
import asyncio
import collections
import contextlib
import functools
import itertools
import socket
from collections.abc import Sequence
from . import _staggered
from .types import AddrInfoType, SocketFactoryType
async def start_connection(
addr_infos: Sequence[AddrInfoType],
*,
local_addr_infos: Sequence[AddrInfoType] | None = None,
happy_eyeballs_delay: float | None = None,
interleave: int | None = None,
loop: asyncio.AbstractEventLoop | None = None,
socket_factory: SocketFactoryType | None = None,
) -> socket.socket:
"""
Connect to a TCP server.
Create a socket connection to a specified destination. The
destination is specified as a list of AddrInfoType tuples as
returned from getaddrinfo().
The arguments are, in order:
* ``family``: the address family, e.g. ``socket.AF_INET`` or
``socket.AF_INET6``.
* ``type``: the socket type, e.g. ``socket.SOCK_STREAM`` or
``socket.SOCK_DGRAM``.
* ``proto``: the protocol, e.g. ``socket.IPPROTO_TCP`` or
``socket.IPPROTO_UDP``.
* ``canonname``: the canonical name of the address, e.g.
``"www.python.org"``.
* ``sockaddr``: the socket address
This method is a coroutine which will try to establish the connection
in the background. When successful, the coroutine returns a
socket.
The expected use case is to use this method in conjunction with
loop.create_connection() to establish a connection to a server::
socket = await start_connection(addr_infos)
transport, protocol = await loop.create_connection(
MyProtocol, sock=socket, ...)
"""
if not addr_infos:
raise ValueError("addr_infos must not be empty")
current_loop = loop or asyncio.get_running_loop()
single_addr_info = len(addr_infos) == 1
if happy_eyeballs_delay is not None and interleave is None:
# If using happy eyeballs, default to interleave addresses by family
interleave = 1
if interleave and not single_addr_info:
addr_infos = _interleave_addrinfos(addr_infos, interleave)
sock: socket.socket | None = None
# uvloop can raise RuntimeError instead of OSError
exceptions: list[list[OSError | RuntimeError]] = []
if happy_eyeballs_delay is None or single_addr_info:
# not using happy eyeballs
for addrinfo in addr_infos:
try:
sock = await _connect_sock(
current_loop,
exceptions,
addrinfo,
local_addr_infos,
None,
socket_factory,
)
break
except (RuntimeError, OSError):
continue
else: # using happy eyeballs
open_sockets: set[socket.socket] = set()
try:
sock, _, _ = await _staggered.staggered_race(
(
functools.partial(
_connect_sock,
current_loop,
exceptions,
addrinfo,
local_addr_infos,
open_sockets,
socket_factory,
)
for addrinfo in addr_infos
),
happy_eyeballs_delay,
)
finally:
# If we have a winner, staggered_race will
# cancel the other tasks, however there is a
# small race window where any of the other tasks
# can be done before they are cancelled which
# will leave the socket open. To avoid this problem
# we pass a set to _connect_sock to keep track of
# the open sockets and close them here if there
# are any "runner up" sockets.
for s in open_sockets:
if s is not sock:
with contextlib.suppress(OSError):
s.close()
open_sockets = None # type: ignore[assignment]
if sock is None:
all_exceptions = [exc for sub in exceptions for exc in sub]
try:
first_exception = all_exceptions[0]
if len(all_exceptions) == 1:
raise first_exception
else:
# If they all have the same str(), raise one.
model = str(first_exception)
if all(str(exc) == model for exc in all_exceptions):
raise first_exception
# Raise a combined exception so the user can see all
# the various error messages.
msg = "Multiple exceptions: {}".format(
", ".join(str(exc) for exc in all_exceptions)
)
# If the errno is the same for all exceptions, raise
# an OSError with that errno.
if isinstance(first_exception, OSError):
first_errno = first_exception.errno
if all(
isinstance(exc, OSError) and exc.errno == first_errno
for exc in all_exceptions
):
raise OSError(first_errno, msg)
elif isinstance(first_exception, RuntimeError) and all(
isinstance(exc, RuntimeError) for exc in all_exceptions
):
raise RuntimeError(msg)
# We have a mix of OSError and RuntimeError
# so we have to pick which one to raise.
# and we raise OSError for compatibility
raise OSError(msg)
finally:
all_exceptions = None # type: ignore[assignment]
exceptions = None # type: ignore[assignment]
return sock
async def _connect_sock(
loop: asyncio.AbstractEventLoop,
exceptions: list[list[OSError | RuntimeError]],
addr_info: AddrInfoType,
local_addr_infos: Sequence[AddrInfoType] | None = None,
open_sockets: set[socket.socket] | None = None,
socket_factory: SocketFactoryType | None = None,
) -> socket.socket:
"""
Create, bind and connect one socket.
If open_sockets is passed, add the socket to the set of open sockets.
Any failure caught here will remove the socket from the set and close it.
Callers can use this set to close any sockets that are not the winner
of all staggered tasks in the result there are runner up sockets aka
multiple winners.
"""
my_exceptions: list[OSError | RuntimeError] = []
exceptions.append(my_exceptions)
family, type_, proto, _, address = addr_info
sock = None
try:
if socket_factory is not None:
sock = socket_factory(addr_info)
else:
sock = socket.socket(family=family, type=type_, proto=proto)
if open_sockets is not None:
open_sockets.add(sock)
sock.setblocking(False)
if local_addr_infos is not None:
for lfamily, _, _, _, laddr in local_addr_infos:
# skip local addresses of different family
if lfamily != family:
continue
try:
sock.bind(laddr)
break
except OSError as exc:
msg = (
f"error while attempting to bind on "
f"address {laddr!r}: "
f"{(exc.strerror or '').lower()}"
)
exc = OSError(exc.errno, msg)
my_exceptions.append(exc)
else: # all bind attempts failed
if my_exceptions:
raise my_exceptions.pop()
else:
raise OSError(f"no matching local address with {family=} found")
await loop.sock_connect(sock, address)
return sock
except (RuntimeError, OSError) as exc:
my_exceptions.append(exc)
if sock is not None:
if open_sockets is not None:
open_sockets.remove(sock)
try:
sock.close()
except OSError as e:
my_exceptions.append(e)
raise
raise
except:
if sock is not None:
if open_sockets is not None:
open_sockets.remove(sock)
try:
sock.close()
except OSError as e:
my_exceptions.append(e)
raise
raise
finally:
exceptions = my_exceptions = None # type: ignore[assignment]
def _interleave_addrinfos(
addrinfos: Sequence[AddrInfoType], first_address_family_count: int = 1
) -> list[AddrInfoType]:
"""Interleave list of addrinfo tuples by family."""
# Group addresses by family
addrinfos_by_family: collections.OrderedDict[int, list[AddrInfoType]] = (
collections.OrderedDict()
)
for addr in addrinfos:
family = addr[0]
if family not in addrinfos_by_family:
addrinfos_by_family[family] = []
addrinfos_by_family[family].append(addr)
addrinfos_lists = list(addrinfos_by_family.values())
reordered: list[AddrInfoType] = []
if first_address_family_count > 1:
reordered.extend(addrinfos_lists[0][: first_address_family_count - 1])
del addrinfos_lists[0][: first_address_family_count - 1]
reordered.extend(
a
for a in itertools.chain.from_iterable(itertools.zip_longest(*addrinfos_lists))
if a is not None
)
return reordered

View File

@ -0,0 +1,14 @@
"""Types for aiohappyeyeballs."""
import socket
from collections.abc import Callable
AddrInfoType = tuple[
int | socket.AddressFamily,
int | socket.SocketKind,
int,
str,
tuple, # type: ignore[type-arg]
]
SocketFactoryType = Callable[[AddrInfoType], socket.socket]

View File

@ -0,0 +1,92 @@
"""Utility functions for aiohappyeyeballs."""
import ipaddress
import socket
from .types import AddrInfoType
def addr_to_addr_infos(
addr: tuple[str, int, int, int] | tuple[str, int, int] | tuple[str, int] | None,
) -> list[AddrInfoType] | None:
"""Convert an address tuple to a list of addr_info tuples."""
if addr is None:
return None
host = addr[0]
port = addr[1]
is_ipv6 = ":" in host
if is_ipv6:
flowinfo = 0
scopeid = 0
addr_len = len(addr)
if addr_len >= 4:
scopeid = addr[3] # type: ignore[misc]
if addr_len >= 3:
flowinfo = addr[2] # type: ignore[misc]
addr = (host, port, flowinfo, scopeid)
family = socket.AF_INET6
else:
addr = (host, port)
family = socket.AF_INET
return [(family, socket.SOCK_STREAM, socket.IPPROTO_TCP, "", addr)]
def pop_addr_infos_interleave(
addr_infos: list[AddrInfoType], interleave: int | None = None
) -> None:
"""
Pop addr_info from the list of addr_infos by family up to interleave times.
The interleave parameter is used to know how many addr_infos for
each family should be popped of the top of the list.
"""
seen: dict[int, int] = {}
if interleave is None:
interleave = 1
to_remove: list[AddrInfoType] = []
for addr_info in addr_infos:
family = addr_info[0]
if family not in seen:
seen[family] = 0
if seen[family] < interleave:
to_remove.append(addr_info)
seen[family] += 1
for addr_info in to_remove:
addr_infos.remove(addr_info)
def _addr_tuple_to_ip_address(
addr: tuple[str, int] | tuple[str, int, int, int],
) -> tuple[ipaddress.IPv4Address, int] | tuple[ipaddress.IPv6Address, int, int, int]:
"""Convert an address tuple to an IPv4Address."""
return (ipaddress.ip_address(addr[0]), *addr[1:])
def remove_addr_infos(
addr_infos: list[AddrInfoType],
addr: tuple[str, int] | tuple[str, int, int, int],
) -> None:
"""
Remove an address from the list of addr_infos.
The addr value is typically the return value of
sock.getpeername().
"""
bad_addrs_infos: list[AddrInfoType] = []
for addr_info in addr_infos:
if addr_info[-1] == addr:
bad_addrs_infos.append(addr_info)
if bad_addrs_infos:
for bad_addr_info in bad_addrs_infos:
addr_infos.remove(bad_addr_info)
return
# Slow path in case addr is formatted differently
match_addr = _addr_tuple_to_ip_address(addr)
for addr_info in addr_infos:
if match_addr == _addr_tuple_to_ip_address(addr_info[-1]):
bad_addrs_infos.append(addr_info)
if bad_addrs_infos:
for bad_addr_info in bad_addrs_infos:
addr_infos.remove(bad_addr_info)
return
raise ValueError(f"Address {addr} not found in addr_infos")

View File

@ -0,0 +1,257 @@
Metadata-Version: 2.4
Name: aiohttp
Version: 3.14.1
Summary: Async http client/server framework (asyncio)
Maintainer-email: aiohttp team <team@aiohttp.org>
License: Apache-2.0 AND MIT
Project-URL: Homepage, https://github.com/aio-libs/aiohttp
Project-URL: Chat: Matrix, https://matrix.to/#/#aio-libs:matrix.org
Project-URL: Chat: Matrix Space, https://matrix.to/#/#aio-libs-space:matrix.org
Project-URL: CI: GitHub Actions, https://github.com/aio-libs/aiohttp/actions?query=workflow%3ACI
Project-URL: Coverage: codecov, https://codecov.io/github/aio-libs/aiohttp
Project-URL: Docs: Changelog, https://docs.aiohttp.org/en/stable/changes.html
Project-URL: Docs: RTD, https://docs.aiohttp.org
Project-URL: GitHub: issues, https://github.com/aio-libs/aiohttp/issues
Project-URL: GitHub: repo, https://github.com/aio-libs/aiohttp
Classifier: Development Status :: 5 - Production/Stable
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Operating System :: POSIX
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Internet :: WWW/HTTP
Requires-Python: >=3.10
Description-Content-Type: text/x-rst
License-File: LICENSE.txt
License-File: vendor/llhttp/LICENSE
Requires-Dist: aiohappyeyeballs>=2.5.0
Requires-Dist: aiosignal>=1.4.0
Requires-Dist: async-timeout<6.0,>=4.0; python_version < "3.11"
Requires-Dist: attrs>=17.3.0
Requires-Dist: frozenlist>=1.1.1
Requires-Dist: multidict<7.0,>=4.5
Requires-Dist: propcache>=0.2.0
Requires-Dist: typing_extensions>=4.4; python_version < "3.13"
Requires-Dist: yarl<2.0,>=1.17.0
Provides-Extra: speedups
Requires-Dist: aiodns>=3.3.0; (sys_platform != "android" and sys_platform != "ios") and extra == "speedups"
Requires-Dist: Brotli>=1.2; (platform_python_implementation == "CPython" and sys_platform != "android" and sys_platform != "ios") and extra == "speedups"
Requires-Dist: brotlicffi>=1.2; platform_python_implementation != "CPython" and extra == "speedups"
Requires-Dist: backports.zstd; (platform_python_implementation == "CPython" and python_version < "3.14" and sys_platform != "android" and sys_platform != "ios") and extra == "speedups"
Dynamic: license-file
==================================
Async http client/server framework
==================================
.. image:: https://raw.githubusercontent.com/aio-libs/aiohttp/master/docs/aiohttp-plain.svg
:height: 64px
:width: 64px
:alt: aiohttp logo
|
.. image:: https://github.com/aio-libs/aiohttp/workflows/CI/badge.svg
:target: https://github.com/aio-libs/aiohttp/actions?query=workflow%3ACI
:alt: GitHub Actions status for master branch
.. image:: https://codecov.io/gh/aio-libs/aiohttp/branch/master/graph/badge.svg
:target: https://codecov.io/gh/aio-libs/aiohttp
:alt: codecov.io status for master branch
.. image:: https://badge.fury.io/py/aiohttp.svg
:target: https://pypi.org/project/aiohttp
:alt: Latest PyPI package version
.. image:: https://img.shields.io/pypi/dm/aiohttp
:target: https://pypistats.org/packages/aiohttp
:alt: Downloads count
.. image:: https://readthedocs.org/projects/aiohttp/badge/?version=latest
:target: https://docs.aiohttp.org/
:alt: Latest Read The Docs
.. image:: https://img.shields.io/endpoint?url=https://codspeed.io/badge.json
:target: https://codspeed.io/aio-libs/aiohttp
:alt: Codspeed.io status for aiohttp
Key Features
============
- Supports both client and server side of HTTP protocol.
- Supports both client and server Web-Sockets out-of-the-box and avoids
Callback Hell.
- Provides Web-server with middleware and pluggable routing.
Getting started
===============
Client
------
To get something from the web:
.. code-block:: python
import aiohttp
import asyncio
async def main():
async with aiohttp.ClientSession() as session:
async with session.get('http://python.org') as response:
print("Status:", response.status)
print("Content-type:", response.headers['content-type'])
html = await response.text()
print("Body:", html[:15], "...")
asyncio.run(main())
This prints:
.. code-block::
Status: 200
Content-type: text/html; charset=utf-8
Body: <!doctype html> ...
Coming from `requests <https://requests.readthedocs.io/>`_ ? Read `why we need so many lines <https://aiohttp.readthedocs.io/en/latest/http_request_lifecycle.html>`_.
Server
------
An example using a simple server:
.. code-block:: python
# examples/server_simple.py
from aiohttp import web
async def handle(request):
name = request.match_info.get('name', "Anonymous")
text = "Hello, " + name
return web.Response(text=text)
async def wshandle(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type == web.WSMsgType.text:
await ws.send_str("Hello, {}".format(msg.data))
elif msg.type == web.WSMsgType.binary:
await ws.send_bytes(msg.data)
elif msg.type == web.WSMsgType.close:
break
return ws
app = web.Application()
app.add_routes([web.get('/', handle),
web.get('/echo', wshandle),
web.get('/{name}', handle)])
if __name__ == '__main__':
web.run_app(app)
Documentation
=============
https://aiohttp.readthedocs.io/
Demos
=====
https://github.com/aio-libs/aiohttp-demos
External links
==============
* `Third party libraries
<http://aiohttp.readthedocs.io/en/latest/third_party.html>`_
* `Built with aiohttp
<http://aiohttp.readthedocs.io/en/latest/built_with.html>`_
* `Powered by aiohttp
<http://aiohttp.readthedocs.io/en/latest/powered_by.html>`_
Feel free to make a Pull Request for adding your link to these pages!
Communication channels
======================
*aio-libs Discussions*: https://github.com/aio-libs/aiohttp/discussions
*Matrix*: `#aio-libs:matrix.org <https://matrix.to/#/#aio-libs:matrix.org>`_
We support `Stack Overflow
<https://stackoverflow.com/questions/tagged/aiohttp>`_.
Please add *aiohttp* tag to your question there.
Requirements
============
- attrs_
- multidict_
- yarl_
- frozenlist_
Optionally you may install the aiodns_ library (highly recommended for sake of speed).
.. _aiodns: https://pypi.python.org/pypi/aiodns
.. _attrs: https://github.com/python-attrs/attrs
.. _multidict: https://pypi.python.org/pypi/multidict
.. _frozenlist: https://pypi.org/project/frozenlist/
.. _yarl: https://pypi.python.org/pypi/yarl
.. _async-timeout: https://pypi.python.org/pypi/async_timeout
Keepsafe
========
The aiohttp community would like to thank Keepsafe
(https://www.getkeepsafe.com) for its support in the early days of
the project.
Source code
===========
The latest developer version is available in a GitHub repository:
https://github.com/aio-libs/aiohttp
Benchmarks
==========
If you are interested in efficiency, the AsyncIO community maintains a
list of benchmarks on the official wiki:
https://github.com/python/asyncio/wiki/Benchmarks
--------
.. image:: https://img.shields.io/matrix/aio-libs:matrix.org?label=Discuss%20on%20Matrix%20at%20%23aio-libs%3Amatrix.org&logo=matrix&server_fqdn=matrix.org&style=flat
:target: https://matrix.to/#/%23aio-libs:matrix.org
:alt: Matrix Room — #aio-libs:matrix.org
.. image:: https://img.shields.io/matrix/aio-libs-space:matrix.org?label=Discuss%20on%20Matrix%20at%20%23aio-libs-space%3Amatrix.org&logo=matrix&server_fqdn=matrix.org&style=flat
:target: https://matrix.to/#/%23aio-libs-space:matrix.org
:alt: Matrix Space — #aio-libs-space:matrix.org
.. image:: https://insights.linuxfoundation.org/api/badge/health-score?project=aiohttp
:target: https://insights.linuxfoundation.org/project/aiohttp
:alt: LFX Health Score

View File

@ -0,0 +1,138 @@
aiohttp-3.14.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
aiohttp-3.14.1.dist-info/METADATA,sha256=lSrt4tDtmd7yhoDSTEq0NNoU8cSDjfDJAuOCCHWKil0,8262
aiohttp-3.14.1.dist-info/RECORD,,
aiohttp-3.14.1.dist-info/WHEEL,sha256=Tc3fF66yn9Kh-hkUUsdKQyuB9Lw0CDoeANnEbSVc3f4,190
aiohttp-3.14.1.dist-info/licenses/LICENSE.txt,sha256=Lkvl_GxMcqRm_LZl1ybgSaaJGYH-U2xPBLY2Z0lGHSM,11313
aiohttp-3.14.1.dist-info/licenses/vendor/llhttp/LICENSE,sha256=YoFo1ou1qKF-C777O9dOMm4e3CBYPXb1L0V8gskhhn0,1069
aiohttp-3.14.1.dist-info/top_level.txt,sha256=iv-JIaacmTl-hSho3QmphcKnbRRYx1st47yjz_178Ro,8
aiohttp/.hash/_cparser.pxd.hash,sha256=1xYKvgB1DahaZj6GMSLJ9qi5KmoJDD8ZPp3CIxah0ig,121
aiohttp/.hash/_find_header.pxd.hash,sha256=_mbpD6vM-CVCKq3ulUvsOAz5Wdo88wrDzfpOsMQaMNA,125
aiohttp/.hash/_http_parser.pyx.hash,sha256=D15N0PIazROldZ2ezxeuuu4VuQP9OxrN8sutpdcL6TA,125
aiohttp/.hash/_http_writer.pyx.hash,sha256=eqqhtJepEtpikWbd_gUoZOWyEBUwmHCd1fq2jfIrXSg,125
aiohttp/.hash/hdrs.py.hash,sha256=cBtTPqXxWpYN--0v2ukVWjjfVbnT3Csii1PkBJBCz8E,116
aiohttp/__init__.py,sha256=1dDCbOmlrF1FnB_LRnU-jVKY1XngQ9guX1ZasoTH-TY,8339
aiohttp/__pycache__/__init__.cpython-312.pyc,,
aiohttp/__pycache__/_cookie_helpers.cpython-312.pyc,,
aiohttp/__pycache__/abc.cpython-312.pyc,,
aiohttp/__pycache__/base_protocol.cpython-312.pyc,,
aiohttp/__pycache__/client.cpython-312.pyc,,
aiohttp/__pycache__/client_exceptions.cpython-312.pyc,,
aiohttp/__pycache__/client_middleware_digest_auth.cpython-312.pyc,,
aiohttp/__pycache__/client_middlewares.cpython-312.pyc,,
aiohttp/__pycache__/client_proto.cpython-312.pyc,,
aiohttp/__pycache__/client_reqrep.cpython-312.pyc,,
aiohttp/__pycache__/client_ws.cpython-312.pyc,,
aiohttp/__pycache__/compression_utils.cpython-312.pyc,,
aiohttp/__pycache__/connector.cpython-312.pyc,,
aiohttp/__pycache__/cookiejar.cpython-312.pyc,,
aiohttp/__pycache__/formdata.cpython-312.pyc,,
aiohttp/__pycache__/hdrs.cpython-312.pyc,,
aiohttp/__pycache__/helpers.cpython-312.pyc,,
aiohttp/__pycache__/http.cpython-312.pyc,,
aiohttp/__pycache__/http_exceptions.cpython-312.pyc,,
aiohttp/__pycache__/http_parser.cpython-312.pyc,,
aiohttp/__pycache__/http_websocket.cpython-312.pyc,,
aiohttp/__pycache__/http_writer.cpython-312.pyc,,
aiohttp/__pycache__/log.cpython-312.pyc,,
aiohttp/__pycache__/multipart.cpython-312.pyc,,
aiohttp/__pycache__/payload.cpython-312.pyc,,
aiohttp/__pycache__/payload_streamer.cpython-312.pyc,,
aiohttp/__pycache__/pytest_plugin.cpython-312.pyc,,
aiohttp/__pycache__/resolver.cpython-312.pyc,,
aiohttp/__pycache__/streams.cpython-312.pyc,,
aiohttp/__pycache__/tcp_helpers.cpython-312.pyc,,
aiohttp/__pycache__/test_utils.cpython-312.pyc,,
aiohttp/__pycache__/tracing.cpython-312.pyc,,
aiohttp/__pycache__/typedefs.cpython-312.pyc,,
aiohttp/__pycache__/web.cpython-312.pyc,,
aiohttp/__pycache__/web_app.cpython-312.pyc,,
aiohttp/__pycache__/web_exceptions.cpython-312.pyc,,
aiohttp/__pycache__/web_fileresponse.cpython-312.pyc,,
aiohttp/__pycache__/web_log.cpython-312.pyc,,
aiohttp/__pycache__/web_middlewares.cpython-312.pyc,,
aiohttp/__pycache__/web_protocol.cpython-312.pyc,,
aiohttp/__pycache__/web_request.cpython-312.pyc,,
aiohttp/__pycache__/web_response.cpython-312.pyc,,
aiohttp/__pycache__/web_routedef.cpython-312.pyc,,
aiohttp/__pycache__/web_runner.cpython-312.pyc,,
aiohttp/__pycache__/web_server.cpython-312.pyc,,
aiohttp/__pycache__/web_urldispatcher.cpython-312.pyc,,
aiohttp/__pycache__/web_ws.cpython-312.pyc,,
aiohttp/__pycache__/worker.cpython-312.pyc,,
aiohttp/_cookie_helpers.py,sha256=uOe8v5yx9N9N2JHFJiQOjuN2SiIV7D1lxnTfrXc6oEU,14092
aiohttp/_cparser.pxd,sha256=nvnwTkOaMAIazM-OUB6H66LpsNNeUG3nFxbSSLQoQEM,4336
aiohttp/_find_header.pxd,sha256=0GfwFCPN2zxEKTO1_MA5sYq2UfzsG8kcV3aTqvwlz3g,68
aiohttp/_headers.pxi,sha256=n701k28dVPjwRnx5j6LpJhLTfj7dqu2vJt7f0O60Oyg,2007
aiohttp/_http_parser.cpython-312-x86_64-linux-gnu.so,sha256=3gTnbWntd2VXGfBrHeUH3BYFoLUC_2n0WJZZ76wPR2Q,2971648
aiohttp/_http_parser.pyx,sha256=mNLUenKTRZkNlXXc9oxVDeXVJ31gsrI4XR69jrDWxPw,33860
aiohttp/_http_writer.cpython-312-x86_64-linux-gnu.so,sha256=FaMRMxE6_VwOwd6dPNhRn_7JMDsvad3qvrZNvR-OrdA,474400
aiohttp/_http_writer.pyx,sha256=FmhqRLv-qyvL2BJvVwk128VFqwYMb1b1vHLPCt3awXA,4823
aiohttp/_websocket/.hash/mask.pxd.hash,sha256=Y0zBddk_ck3pi9-BFzMcpkcvCKvwvZ4GTtZFb9u1nxQ,128
aiohttp/_websocket/.hash/mask.pyx.hash,sha256=90owpXYM8_kIma4KUcOxhWSk-Uv4NVMBoCYeFM1B3d0,128
aiohttp/_websocket/.hash/reader_c.pxd.hash,sha256=DM51AsKpHR3Bl2EIx0eUSy1uvusTDfylNeRCMT66erk,132
aiohttp/_websocket/__init__.py,sha256=Mar3R9_vBN_Ea4lsW7iTAVXD7OKswKPGqF5xgSyt77k,44
aiohttp/_websocket/__pycache__/__init__.cpython-312.pyc,,
aiohttp/_websocket/__pycache__/helpers.cpython-312.pyc,,
aiohttp/_websocket/__pycache__/models.cpython-312.pyc,,
aiohttp/_websocket/__pycache__/reader.cpython-312.pyc,,
aiohttp/_websocket/__pycache__/reader_c.cpython-312.pyc,,
aiohttp/_websocket/__pycache__/reader_py.cpython-312.pyc,,
aiohttp/_websocket/__pycache__/writer.cpython-312.pyc,,
aiohttp/_websocket/helpers.py,sha256=lMDbpiZefmHTGNxjpe6K09ZlF048KxzcOvbh-ZYqvg0,5026
aiohttp/_websocket/mask.cpython-312-x86_64-linux-gnu.so,sha256=phpHU3AXOq0nAtNgxac3ZtW2sxD-re_5ZeAl5V8lNgc,253760
aiohttp/_websocket/mask.pxd,sha256=sBmZ1Amym9kW4Ge8lj1fLZ7mPPya4LzLdpkQExQXv5M,112
aiohttp/_websocket/mask.pyx,sha256=BHjOtV0O0w7xp9p0LNADRJvGmgfPn9sGeJvSs0fL__4,1397
aiohttp/_websocket/models.py,sha256=gJLskxkUKakYFsVMdjOclSjt41rQWRCL4RfRB_wmwO0,2952
aiohttp/_websocket/reader.py,sha256=eC4qS0c5sOeQ2ebAHLaBpIaTVFaSKX79pY2xvh3Pqyw,1030
aiohttp/_websocket/reader_c.cpython-312-x86_64-linux-gnu.so,sha256=Zk-kBzozgh0cifrVwO-ZUVXRJstKqbX7_9lhy9qC_R4,1789112
aiohttp/_websocket/reader_c.pxd,sha256=l-ODGpJpOx4FxpsCtkRyITmmRvBlRo8mv87qNgeQZbo,2683
aiohttp/_websocket/reader_c.py,sha256=vf7Y6JB6hpLE17OQ39IhO70ga_DXFYksfv45M5n1wOU,20073
aiohttp/_websocket/reader_py.py,sha256=vf7Y6JB6hpLE17OQ39IhO70ga_DXFYksfv45M5n1wOU,20073
aiohttp/_websocket/writer.py,sha256=JA4ejOQMCvAvIXe_0f6uRx_aGSO4-zVUc1HJBZ125ak,11259
aiohttp/abc.py,sha256=KvpM3wDEWl_iIdNQX3NEnJf3pYAtgxynAvp5s4JkNrU,7536
aiohttp/base_protocol.py,sha256=bL6dSlNacbmln20Plez_gqYXTcFeg-V1hkHVDk4s8vU,4688
aiohttp/client.py,sha256=PzaLAKgPMytcA75z1ZIEjTBzTWR3U7wT66f38OKTwmI,64312
aiohttp/client_exceptions.py,sha256=XvJ57ppYs2IubcY3efBUkSpPElwQDqt3SWU_yssvjyI,11609
aiohttp/client_middleware_digest_auth.py,sha256=Z8ufMnBF8RQ_JaVfG3bUbAoRua_kC7RSIexQM2zm9wQ,19042
aiohttp/client_middlewares.py,sha256=kP5N9CMzQPMGPIEydeVUiLUTLsw8Vl8Gr4qAWYdu3vM,1918
aiohttp/client_proto.py,sha256=u6AZU05mZxAFG4saTaxn5lVN9vy4cmgvF9k7HQG260A,12633
aiohttp/client_reqrep.py,sha256=RWyXzuIi0NETNeuNADMbWPGd-mxcpVLsVQM0N582pU8,54704
aiohttp/client_ws.py,sha256=PiMLbWN6IBEXGiWuqL1KyQqIAydDGsWMitX_pd8cjLo,19468
aiohttp/compression_utils.py,sha256=_HTGK9dZOojGd4kyrk2A5yAt1pzVyKJh4AH5eVH0DQ8,15840
aiohttp/connector.py,sha256=0iYWUWjhc8zDYwWd-J4mtfhjojzGRRcbnQbnuwY-njU,70036
aiohttp/cookiejar.py,sha256=Y80b6_hSiDi4lN0iOb3X5983VONf-i4GhRH447gzTr0,25815
aiohttp/formdata.py,sha256=zRWW2ODYkWeahDOd-hwra-__EUizdB2bf6hAMgkCKhc,6514
aiohttp/hdrs.py,sha256=pGrWw6L6-NJqLGr8GiIQzjeaI_J5n857JqAfbOWkBkI,5106
aiohttp/helpers.py,sha256=dSbEmGl8XsLU2zwmD0dyp4wHHvHrH5TFaofkSH-OmsA,33017
aiohttp/http.py,sha256=CHXxFtOXK85RNijYkfpjvjCbKlJc3hIGRsCFAnh83hk,2077
aiohttp/http_exceptions.py,sha256=I1nyopt1sYV0GqpW2ra4pnCmFcOqMoDC4dELghEk2lI,3115
aiohttp/http_parser.py,sha256=nrXK1evVj04TjpbOu4tOx4w0yUlzG2PaEFxq0wvmfNY,45170
aiohttp/http_websocket.py,sha256=8jVNshtgNPSDnab0Dc1lAO7HFvtT7y79eZKjUXX6e28,983
aiohttp/http_writer.py,sha256=bXHXXWkEnySdYX3cCmYCFgNFsnxwPNCs-0nMjSPDRAo,12602
aiohttp/log.py,sha256=BbNKx9e3VMIm0xYjZI0IcBBoS7wjdeIeSaiJE7-qK2g,325
aiohttp/multipart.py,sha256=CJB4szZ8Zhv4htswXvSDfwY8HjlVGZFyvzGXIYo7b50,44179
aiohttp/payload.py,sha256=MXcl_K0gcblkN_YFXE_Up6N7IQwjH8uM4HTfn9Axbmg,41480
aiohttp/payload_streamer.py,sha256=PGqJKt_1ca_7i6p1UgU8jrpO1toOt5EQDQMOK9LjtV4,2225
aiohttp/py.typed,sha256=sow9soTwP9T_gEAQSVh7Gb8855h04Nwmhs2We-JRgZM,7
aiohttp/pytest_plugin.py,sha256=eGhRoy0TKNOQ3mtabkpoMgt0JThnVDr406h_kyZkQe8,12976
aiohttp/resolver.py,sha256=jmrAUnhayFdSjTsCt2_coosdVmc5RgEB5Agr65Q0sQk,9954
aiohttp/streams.py,sha256=k-XDoAfb3P8t5sHvWyEONXzYHoZ4JmJYr_aZB9CQF6c,24127
aiohttp/tcp_helpers.py,sha256=BSadqVWaBpMFDRWnhaaR941N9MiDZ7bdTrxgCb0CW-M,961
aiohttp/test_utils.py,sha256=RqqoQTTI4VpZDvOcLMmQgxisuRMCIX0qOjG019EtEpM,24636
aiohttp/tracing.py,sha256=4mokNTKdX5YuoSrvVnG4D7WTPMwe-UlYHVYxQjRv4Fw,14500
aiohttp/typedefs.py,sha256=I-V6S4T73fI_wkJfBYiL7otEeRvVHmSdsk-YYPR6Fj0,1672
aiohttp/web.py,sha256=gsyxRSPWV3iZjPUbOR3dIGcp1jm2ltBtXwkPh91co3Q,18426
aiohttp/web_app.py,sha256=hmT_DWXKwHMqmFBLE8JHhevLaaTST73ikovIIx_Tw6k,19379
aiohttp/web_exceptions.py,sha256=CsEG88dQINYSFpklCE_7bZgL1IVjHoy5dlTSVQGHPRQ,10354
aiohttp/web_fileresponse.py,sha256=5vlf-OmfgFxG5O1zGrzk-SPRkbs58izLlbQldT-DHXE,16404
aiohttp/web_log.py,sha256=lJCDsZ2xJp7HqSuijaTZyqxJ0oNWPhwxJE1FVGUWwWU,8487
aiohttp/web_middlewares.py,sha256=OIfnnCHJgRnKjzDjHoc_VzNcLuVf9y8kv5aTzM2VGvc,4152
aiohttp/web_protocol.py,sha256=RyGK7u4Lpl8yC2rSbHcKiYhSDbKSbHuA-GZTaN8q-RY,31092
aiohttp/web_request.py,sha256=o99Os7_M_C4poSc58el9q44VGO6VctJaJMFKhi7F6R8,31867
aiohttp/web_response.py,sha256=7EjxpTW4a6du5X0utXenUbSO6fbY_MgfINppJUEre-M,30783
aiohttp/web_routedef.py,sha256=S_uRfJ87LCM_fFTAIrqYTWiIqDAk0pLLYdUr1vGU9BM,6057
aiohttp/web_runner.py,sha256=PGUdZs_d2qzM3Tnb6yCQQoxfwqQZFuVr_NGIJBZq9Ms,12708
aiohttp/web_server.py,sha256=LawT47SJuLSIYgkAWL_haFgH6lVyuoD3zyFTvBatvsE,3170
aiohttp/web_urldispatcher.py,sha256=lc9Cou6tP8YJTJS2MEQWVcJV9C5dFKTyhdCLjLtkOzU,43893
aiohttp/web_ws.py,sha256=fqE-c95dxvwkZfm8M-vKeP5pCteHCmrhAdI-1o4J348,27455
aiohttp/worker.py,sha256=osuMCad7kBmL7FeR6EEEdsfkDqOaBDYvAzHMgW_hsIc,8506

View File

@ -0,0 +1,7 @@
Wheel-Version: 1.0
Generator: setuptools (82.0.1)
Root-Is-Purelib: false
Tag: cp312-cp312-manylinux_2_17_x86_64
Tag: cp312-cp312-manylinux2014_x86_64
Tag: cp312-cp312-manylinux_2_28_x86_64

View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright aio-libs contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,22 @@
MIT License
Copyright © 2018 Fedor Indutny
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to permit
persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1 @@
9ef9f04e439a30021acccf8e501e87eba2e9b0d35e506de71716d248b4284043 /home/runner/work/aiohttp/aiohttp/aiohttp/_cparser.pxd

Some files were not shown because too many files have changed in this diff Show More