Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/easy_tdx/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ async def main():

from .client import AsyncTdxClient, TdxClient
from .config import save_best_ex_host, save_best_host
from .crypto.client import AsyncCryptoClient, CryptoClient
from .ex.client import AsyncExTdxClient, ExTdxClient
from .ex.mac_client import AsyncMacExClient, MacExClient
from .ex.models import KNOWN_EX_HOSTS
Expand Down Expand Up @@ -107,6 +108,9 @@ async def main():
"ExTdxClient",
"AsyncExTdxClient",
"KNOWN_EX_HOSTS",
# 加密货币(Binance)
"CryptoClient",
"AsyncCryptoClient",
# 工具
"ping_all",
"ping_mac_all",
Expand Down
4 changes: 3 additions & 1 deletion src/easy_tdx/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,10 @@ def _run_tray_server() -> None:
from easy_tdx.tray import run_with_tray
from easy_tdx.web import create_app

# enable_ex=True:打包态默认开启扩展市场(美股/港股/期货)。
# 注意不能直接传 create_app(enable_ex 默认 False,会 503)
run_with_tray(
app_factory=create_app,
app_factory=lambda: create_app(enable_ex=True),
host="127.0.0.1",
port=8000,
open_browser=True,
Expand Down
2 changes: 2 additions & 0 deletions src/easy_tdx/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from .cmd_capital import capital_flow
from .cmd_chanlun import chanlun
from .cmd_company import company_info, company_info_content, finance_info
from .cmd_crypto import crypto
from .cmd_ex import ex
from .cmd_factor import factor
from .cmd_finance import f10, fund_flow
Expand Down Expand Up @@ -85,6 +86,7 @@ def cli() -> None:
cli.add_command(company_info)
cli.add_command(company_info_content)
cli.add_command(ex)
cli.add_command(crypto)
cli.add_command(indicator)
cli.add_command(indicator_list)
cli.add_command(offline)
Expand Down
54 changes: 54 additions & 0 deletions src/easy_tdx/cli/cmd_crypto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""加密货币命令(Binance 公共 API,现货)。"""

from __future__ import annotations

import click


@click.group()
def crypto() -> None:
"""加密货币行情(Binance 现货,免费公共 API)。

示例:

easy-tdx crypto kline BTCUSDT --period 1d --table

easy-tdx crypto price ETHUSDT --table
"""
pass


@crypto.command()
@click.argument("symbol")
@click.option("--period", default="1d", help="周期: 1m/5m/15m/30m/1h/4h/1d/1w/1M 等")
@click.option("--count", default=500, type=int, help="K线数量(1..1000)")
@click.option("--table", "use_table", is_flag=True, help="表格输出")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json")
def kline(symbol: str, period: str, count: int, use_table: bool, output_fmt: str) -> None:
"""获取加密货币 K 线(OHLCV)。

SYMBOL: 交易对,如 BTCUSDT(兼容 btc/usdt 写法)。
"""
from ..crypto import CryptoClient
from .output import print_output

fmt = "table" if use_table else output_fmt
df = CryptoClient().klines(symbol, interval=period, limit=count)
print_output(df, fmt)


@crypto.command()
@click.argument("symbol")
@click.option("--table", "use_table", is_flag=True, help="表格输出")
@click.option("--output", "output_fmt", type=click.Choice(["json", "table", "csv"]), default="json")
def price(symbol: str, use_table: bool, output_fmt: str) -> None:
"""获取加密货币最新价。"""
import pandas as pd

from ..crypto import CryptoClient
from .output import print_output

fmt = "table" if use_table else output_fmt
p = CryptoClient().ticker_price(symbol)
df = pd.DataFrame([{"symbol": symbol.upper(), "price": p}])
print_output(df, fmt)
12 changes: 11 additions & 1 deletion src/easy_tdx/cli/cmd_web.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
import threading
import webbrowser

Expand All @@ -14,6 +15,11 @@
@click.option("--tdx-host", default=None, help="TDX 服务器地址(默认自动选择最优)")
@click.option("--tdx-port", default=None, type=int, help="TDX 服务器端口")
@click.option("--reload", is_flag=True, help="开发模式(自动重载)")
@click.option(
"--enable-ex",
is_flag=True,
help="启用扩展市场(美股/港股/期货)行情接口 /api/v1/ex/*(默认关闭)",
)
@click.option(
"--open-browser/--no-open-browser",
default=True,
Expand All @@ -25,6 +31,7 @@ def serve(
tdx_host: str | None,
tdx_port: int | None,
reload: bool,
enable_ex: bool,
open_browser: bool,
) -> None:
"""启动 Web API 服务器(需要安装 easy-tdx[web])。"""
Expand All @@ -47,6 +54,9 @@ def serve(
threading.Timer(1.5, lambda: webbrowser.open(url)).start()

if reload:
# reload 模式由 uvicorn 子进程 import app_factory,开关经环境变量传递
if enable_ex:
os.environ["EASY_TDX_ENABLE_EX"] = "1"
uvicorn.run(
"easy_tdx.web:app_factory",
host=host,
Expand All @@ -57,5 +67,5 @@ def serve(
else:
from easy_tdx.web import create_app

app = create_app(host=tdx_host, port=tdx_port)
app = create_app(host=tdx_host, port=tdx_port, enable_ex=enable_ex)
uvicorn.run(app, host=host, port=port)
5 changes: 5 additions & 0 deletions src/easy_tdx/crypto/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""easy_tdx.crypto — Binance 加密货币行情(免费公共 API,无需 Key)。"""

from .client import VALID_INTERVALS, AsyncCryptoClient, CryptoClient, CryptoError

__all__ = ["CryptoClient", "AsyncCryptoClient", "CryptoError", "VALID_INTERVALS"]
197 changes: 197 additions & 0 deletions src/easy_tdx/crypto/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
"""easy_tdx.crypto — Binance 加密货币行情客户端(免费公共 API,无需 Key)。

数据源: https://api.binance.com/api/v3
- /klines → OHLCV K 线(现货)
- /ticker/price → 最新价
- /ping → 连通性

零第三方依赖(stdlib urllib)。代理:显式 proxy 参数优先,其次 https_proxy 环境变量。

K 线响应(Binance klines 12 列)映射为 DataFrame 列:
datetime(bar 开始时间,UTC 无时区) / open / high / low / close / vol / amount(报价资产成交额)
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
from typing import Any
from urllib import parse
from urllib import request as urlrequest

import pandas as pd

from ..exceptions import TdxError

logger = logging.getLogger(__name__)

_BASE_URL = "https://api.binance.com"
_UA = "Mozilla/5.0 easy-tdx crypto client"

# 支持的 K 线周期(Binance interval 原样透传)
VALID_INTERVALS = frozenset(
{"1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w", "1M"}
)

MAX_LIMIT = 1000


class CryptoError(TdxError):
"""加密货币数据源错误。"""


def normalize_symbol(symbol: str) -> str:
"""交易对归一化:btc/usdt、BTC-USDT、btcusdt 均转为 BTCUSDT。"""
s = symbol.strip().upper().replace("/", "").replace("-", "").replace("_", "")
if not s or ("USDT" not in s and len(s) < 5):
raise CryptoError(f"交易对格式异常: {symbol!r}(如 BTCUSDT)")
return s


def _resolve_proxy(proxy: str | None) -> dict[str, str] | None:
"""显式 proxy 优先,其次 https_proxy / HTTPS_PROXY 环境变量。"""
p = proxy or os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY")
if not p:
return None
if "://" not in p:
p = "http://" + p
return {"https": p, "http": p}


class CryptoClient:
"""同步 Binance 加密货币行情客户端(现货)。

用法::

from easy_tdx.crypto import CryptoClient

c = CryptoClient()
df = c.klines("BTCUSDT", interval="1d", limit=300)
"""

def __init__(
self,
base_url: str = _BASE_URL,
timeout: float = 15.0,
proxy: str | None = None,
) -> None:
self._base_url = base_url.rstrip("/")
self._timeout = timeout
self._proxy = proxy

def ping(self) -> bool:
"""连通性检查(/api/v3/ping)。"""
try:
self._get("/api/v3/ping", {})
return True
except CryptoError:
return False

def klines(
self,
symbol: str,
interval: str = "1d",
limit: int = 500,
) -> pd.DataFrame:
"""获取 K 线(OHLCV)。

Args:
symbol: 交易对(如 BTCUSDT,兼容 btc/usdt、BTC-USDT 写法)。
interval: Binance 周期(1m/5m/15m/30m/1h/4h/1d/1w/1M 等)。
limit: 返回条数(1..1000)。

Returns:
DataFrame[datetime, open, high, low, close, vol, amount]
"""
sym = normalize_symbol(symbol)
if interval not in VALID_INTERVALS:
raise CryptoError(f"不支持的周期 {interval!r},可选: {sorted(VALID_INTERVALS)}")
if not 1 <= limit <= MAX_LIMIT:
raise CryptoError(f"limit 需在 1..{MAX_LIMIT}")
rows = self._get("/api/v3/klines", {"symbol": sym, "interval": interval, "limit": limit})
if not isinstance(rows, list):
raise CryptoError(f"klines 响应异常: {rows!r}")
if not rows:
return pd.DataFrame(
columns=["datetime", "open", "high", "low", "close", "vol", "amount"]
)
df = pd.DataFrame(
[
{
"datetime": pd.Timestamp(int(r[0]), unit="ms", tz="UTC").tz_localize(None),
"open": float(r[1]),
"high": float(r[2]),
"low": float(r[3]),
"close": float(r[4]),
"vol": float(r[5]),
"amount": float(r[7]), # quoteAssetVolume(如 USDT 计)
}
for r in rows
]
)
return df

def ticker_price(self, symbol: str) -> float:
"""获取最新成交价。"""
sym = normalize_symbol(symbol)
data = self._get("/api/v3/ticker/price", {"symbol": sym})
try:
return float(data["price"])
except (KeyError, TypeError, ValueError):
raise CryptoError(f"ticker/price 响应异常: {data!r}") from None

def _get(self, path: str, params: dict[str, Any]) -> Any:
"""GET JSON。参数 urlencode;代理走 ProxyHandler;错误统一转 CryptoError。"""
url = self._base_url + path + "?" + parse.urlencode(params)
req = urlrequest.Request(url, headers={"User-Agent": _UA})
proxies = _resolve_proxy(self._proxy)
opener = (
urlrequest.build_opener(urlrequest.ProxyHandler(proxies))
if proxies
else urlrequest.build_opener()
)
try:
with opener.open(req, timeout=self._timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except urlrequest.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
raise CryptoError(f"Binance API {e.code}: {body[:200]}") from None
except OSError as e:
raise CryptoError(f"Binance API 网络错误: {e}") from None


class AsyncCryptoClient:
"""异步包装:asyncio.to_thread 复用同步实现,零额外依赖。

用法::

from easy_tdx.crypto import AsyncCryptoClient

async def main():
c = AsyncCryptoClient()
df = await c.klines("BTCUSDT", interval="1d", limit=300)
"""

def __init__(
self,
base_url: str = _BASE_URL,
timeout: float = 15.0,
proxy: str | None = None,
) -> None:
self._sync = CryptoClient(base_url, timeout, proxy)

async def ping(self) -> bool:
return await asyncio.to_thread(self._sync.ping)

async def klines(
self,
symbol: str,
interval: str = "1d",
limit: int = 500,
) -> pd.DataFrame:
return await asyncio.to_thread(self._sync.klines, symbol, interval, limit)

async def ticker_price(self, symbol: str) -> float:
return await asyncio.to_thread(self._sync.ticker_price, symbol)
10 changes: 8 additions & 2 deletions src/easy_tdx/web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,33 @@ def create_app(
host: str | None = None,
port: int | None = None,
timeout: float | None = None,
enable_ex: bool = False,
) -> FastAPI:
"""Create and configure the FastAPI application.

Args:
host: TDX server host (None = auto-detect best host).
port: TDX server port (None = default 7709).
timeout: Connection timeout in seconds.
enable_ex: Enable extended-market (US/HK/futures) MAC client;
endpoints under /ex/* return 503 when disabled.

Returns:
Configured FastAPI application instance.
"""
from easy_tdx.web.app import _create_app

return _create_app(host=host, port=port, timeout=timeout)
return _create_app(host=host, port=port, timeout=timeout, enable_ex=enable_ex)


def app_factory() -> FastAPI:
"""Factory function for uvicorn --reload mode."""
import os

from easy_tdx.web.app import _create_app

return _create_app()
enable_ex = os.environ.get("EASY_TDX_ENABLE_EX", "") == "1"
return _create_app(enable_ex=enable_ex)


__all__ = ["create_app", "app_factory"]
Loading
Loading