coinwatch-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coinwatch/__init__.py +4 -0
- coinwatch/alert.py +76 -0
- coinwatch/cli.py +145 -0
- coinwatch/config.py +76 -0
- coinwatch/market.py +44 -0
- coinwatch/notifier.py +82 -0
- coinwatch/report.py +36 -0
- coinwatch/sources/__init__.py +8 -0
- coinwatch/sources/base.py +69 -0
- coinwatch/sources/binance.py +60 -0
- coinwatch/sources/coingecko.py +42 -0
- coinwatch/sources/okx.py +50 -0
- coinwatch/web.py +90 -0
- coinwatch_cli-0.1.0.dist-info/METADATA +250 -0
- coinwatch_cli-0.1.0.dist-info/RECORD +18 -0
- coinwatch_cli-0.1.0.dist-info/WHEEL +4 -0
- coinwatch_cli-0.1.0.dist-info/entry_points.txt +2 -0
- coinwatch_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
coinwatch/__init__.py
ADDED
coinwatch/alert.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Threshold evaluation and alert generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .sources.base import Quote
|
|
10
|
+
|
|
11
|
+
STATE_FILE = Path(".coinwatch_state.json")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_baselines(path: Path = STATE_FILE) -> dict[str, float]:
|
|
15
|
+
try:
|
|
16
|
+
if path.exists():
|
|
17
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
18
|
+
if isinstance(raw, dict):
|
|
19
|
+
return {k: float(v) for k, v in raw.items()}
|
|
20
|
+
except (OSError, ValueError):
|
|
21
|
+
pass
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_baselines(baselines: dict[str, float], path: Path = STATE_FILE) -> None:
|
|
26
|
+
try:
|
|
27
|
+
path.write_text(json.dumps(baselines, indent=2), encoding="utf-8")
|
|
28
|
+
except OSError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def evaluate(
|
|
33
|
+
config: Config,
|
|
34
|
+
quotes: dict[str, Quote],
|
|
35
|
+
state_path: Path = STATE_FILE,
|
|
36
|
+
) -> list[str]:
|
|
37
|
+
"""Return a list of human-readable alert strings for the given quotes."""
|
|
38
|
+
alerts: list[str] = []
|
|
39
|
+
baselines = load_baselines(state_path)
|
|
40
|
+
next_baselines = dict(baselines)
|
|
41
|
+
|
|
42
|
+
for cid, quote in quotes.items():
|
|
43
|
+
# 1. Absolute 24h move.
|
|
44
|
+
if config.change_pct_alert > 0 and quote.change_24h_pct is not None:
|
|
45
|
+
if abs(quote.change_24h_pct) >= config.change_pct_alert:
|
|
46
|
+
direction = "涨" if quote.change_24h_pct >= 0 else "跌"
|
|
47
|
+
alerts.append(
|
|
48
|
+
f"[{quote.symbol}] 24h {direction} {quote.change_24h_pct:+.2f}% "
|
|
49
|
+
f"(阈值 ±{config.change_pct_alert:.1f}%) 现价 ${quote.price:,.2f}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# 2. Static price barriers.
|
|
53
|
+
above = config.price_above.get(cid)
|
|
54
|
+
if above is not None and quote.price >= above:
|
|
55
|
+
alerts.append(f"[{quote.symbol}] 现价 ${quote.price:,.2f} 突破上方 ${above:,.2f}")
|
|
56
|
+
|
|
57
|
+
below = config.price_below.get(cid)
|
|
58
|
+
if below is not None and quote.price <= below:
|
|
59
|
+
alerts.append(f"[{quote.symbol}] 现价 ${quote.price:,.2f} 跌破下方 ${below:,.2f}")
|
|
60
|
+
|
|
61
|
+
# 3. Volume spike against a rolling baseline.
|
|
62
|
+
if config.volume_spike_pct > 0 and quote.volume_24h:
|
|
63
|
+
base = baselines.get(cid)
|
|
64
|
+
if base is None:
|
|
65
|
+
next_baselines[cid] = quote.volume_24h
|
|
66
|
+
else:
|
|
67
|
+
if quote.volume_24h >= base * (1 + config.volume_spike_pct / 100.0):
|
|
68
|
+
alerts.append(
|
|
69
|
+
f"[{quote.symbol}] 成交量异动 +{(quote.volume_24h / base - 1) * 100:.0f}% "
|
|
70
|
+
f"(基线 ${base:,.0f} -> 现 ${quote.volume_24h:,.0f})"
|
|
71
|
+
)
|
|
72
|
+
# Gentle exponential moving average keeps the baseline fresh.
|
|
73
|
+
next_baselines[cid] = base * 0.9 + quote.volume_24h * 0.1
|
|
74
|
+
|
|
75
|
+
save_baselines(next_baselines, state_path)
|
|
76
|
+
return alerts
|
coinwatch/cli.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from . import web as web_module
|
|
13
|
+
from .alert import evaluate
|
|
14
|
+
from .config import DEFAULT_CONFIG_NAME, Config, load_config
|
|
15
|
+
from .market import Market
|
|
16
|
+
from .notifier import notify
|
|
17
|
+
from .report import render_text
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _render_alerts(alerts: list[str]) -> str:
|
|
21
|
+
return "\n".join(f"* {a}" for a in alerts)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _print_source_failures(market: Market) -> None:
|
|
25
|
+
"""Surface *why* a snapshot came back empty instead of leaving users guessing."""
|
|
26
|
+
failures = getattr(market, "last_failures", {}) or {}
|
|
27
|
+
if not failures:
|
|
28
|
+
return
|
|
29
|
+
print("行情源不可用:")
|
|
30
|
+
for name, err in failures.items():
|
|
31
|
+
print(f" ! {name}: {err}")
|
|
32
|
+
print(" -> 常见原因:本机网络到该 API 不通(部分地区需代理)、或被限流。")
|
|
33
|
+
print()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
37
|
+
config = load_config(args.config)
|
|
38
|
+
market = Market()
|
|
39
|
+
quotes = market.snapshot(config.coin_ids, config.watchlist)
|
|
40
|
+
alerts = evaluate(config, quotes)
|
|
41
|
+
|
|
42
|
+
_print_source_failures(market)
|
|
43
|
+
print(render_text(quotes, []))
|
|
44
|
+
if alerts:
|
|
45
|
+
notify(config, "CoinWatch 告警:\n" + _render_alerts(alerts))
|
|
46
|
+
else:
|
|
47
|
+
print("无告警。")
|
|
48
|
+
return 0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
52
|
+
config = load_config(args.config)
|
|
53
|
+
market = Market()
|
|
54
|
+
quotes = market.snapshot(config.coin_ids, config.watchlist)
|
|
55
|
+
_print_source_failures(market)
|
|
56
|
+
print(render_text(quotes, []))
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_watch(args: argparse.Namespace) -> int:
|
|
61
|
+
config = load_config(args.config)
|
|
62
|
+
market = Market()
|
|
63
|
+
interval = args.interval or config.interval_seconds
|
|
64
|
+
print(f"每 {interval}s 轮询一次。Ctrl+C 退出。")
|
|
65
|
+
# Report source outages once, not once per tick.
|
|
66
|
+
reported_failures = False
|
|
67
|
+
try:
|
|
68
|
+
while True:
|
|
69
|
+
quotes = market.snapshot(config.coin_ids, config.watchlist)
|
|
70
|
+
alerts = evaluate(config, quotes)
|
|
71
|
+
if not reported_failures:
|
|
72
|
+
_print_source_failures(market)
|
|
73
|
+
reported_failures = True
|
|
74
|
+
print(render_text(quotes, []))
|
|
75
|
+
if alerts:
|
|
76
|
+
notify(config, "CoinWatch 告警:\n" + _render_alerts(alerts))
|
|
77
|
+
time.sleep(interval)
|
|
78
|
+
except KeyboardInterrupt:
|
|
79
|
+
print("\n已停止。")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_web(args: argparse.Namespace) -> int:
|
|
84
|
+
config = load_config(args.config)
|
|
85
|
+
market = Market()
|
|
86
|
+
web_module.run_web(config, market)
|
|
87
|
+
return 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
91
|
+
example = Path("config.example.toml")
|
|
92
|
+
target = Path(args.config or DEFAULT_CONFIG_NAME)
|
|
93
|
+
if target.exists():
|
|
94
|
+
print(f"{target} 已存在,未覆盖。")
|
|
95
|
+
return 0
|
|
96
|
+
if not example.exists():
|
|
97
|
+
print("未找到 config.example.toml(请在项目根目录运行)。")
|
|
98
|
+
return 1
|
|
99
|
+
shutil.copy(example, target)
|
|
100
|
+
print(f"已生成 {target},请编辑其中的 watchlist / 阈值 / 通知渠道后使用。")
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
105
|
+
parser = argparse.ArgumentParser(
|
|
106
|
+
prog="coinwatch",
|
|
107
|
+
description="本地优先的加密货币行情监控与告警引擎。",
|
|
108
|
+
)
|
|
109
|
+
parser.add_argument(
|
|
110
|
+
"--version", action="version", version=f"coinwatch {__version__}"
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument("--config", default=None, help=f"配置文件路径(默认 {DEFAULT_CONFIG_NAME})")
|
|
113
|
+
sub = parser.add_subparsers(dest="command")
|
|
114
|
+
|
|
115
|
+
sub.add_parser("check", help="单次快照并触发告警")
|
|
116
|
+
sub.add_parser("list", help="列出自选行情")
|
|
117
|
+
watcher = sub.add_parser("watch", help="循环监控")
|
|
118
|
+
watcher.add_argument("--interval", type=int, default=None, help="轮询间隔(秒)")
|
|
119
|
+
sub.add_parser("web", help="启动本地 Web 面板")
|
|
120
|
+
sub.add_parser("init", help="从示例生成 config.toml")
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(argv: list[str] | None = None) -> int:
|
|
125
|
+
parser = build_parser()
|
|
126
|
+
args = parser.parse_args(argv)
|
|
127
|
+
if not args.command:
|
|
128
|
+
parser.print_help()
|
|
129
|
+
return 0
|
|
130
|
+
handlers = {
|
|
131
|
+
"check": cmd_check,
|
|
132
|
+
"list": cmd_list,
|
|
133
|
+
"watch": cmd_watch,
|
|
134
|
+
"web": cmd_web,
|
|
135
|
+
"init": cmd_init,
|
|
136
|
+
}
|
|
137
|
+
try:
|
|
138
|
+
return handlers[args.command](args)
|
|
139
|
+
except FileNotFoundError as exc:
|
|
140
|
+
print(f"错误: {exc}", file=sys.stderr)
|
|
141
|
+
return 1
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
raise SystemExit(main())
|
coinwatch/config.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""TOML configuration loading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG_NAME = "config.toml"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Config:
|
|
15
|
+
"""Runtime configuration."""
|
|
16
|
+
|
|
17
|
+
watchlist: dict[str, str] = field(default_factory=dict)
|
|
18
|
+
thresholds: dict[str, Any] = field(default_factory=dict)
|
|
19
|
+
notify: dict[str, Any] = field(default_factory=dict)
|
|
20
|
+
web: dict[str, Any] = field(default_factory=dict)
|
|
21
|
+
schedule: dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def coin_ids(self) -> list[str]:
|
|
26
|
+
return list(self.watchlist.keys())
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def change_pct_alert(self) -> float:
|
|
30
|
+
return _as_float(self.thresholds.get("change_pct_alert"))
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def volume_spike_pct(self) -> float:
|
|
34
|
+
return _as_float(self.thresholds.get("volume_spike_pct"))
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def price_above(self) -> dict[str, float]:
|
|
38
|
+
return {k: _as_float(v) for k, v in (self.thresholds.get("price_above") or {}).items()}
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def price_below(self) -> dict[str, float]:
|
|
42
|
+
return {k: _as_float(v) for k, v in (self.thresholds.get("price_below") or {}).items()}
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def interval_seconds(self) -> int:
|
|
46
|
+
return int(_as_float(self.schedule.get("interval_seconds"), 300))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _as_float(value: Any, default: float = 0.0) -> float:
|
|
50
|
+
if value is None or value == "":
|
|
51
|
+
return default
|
|
52
|
+
try:
|
|
53
|
+
return float(value)
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
return default
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load_config(path: str | Path | None = None) -> Config:
|
|
59
|
+
"""Parse a TOML config file into a Config object."""
|
|
60
|
+
p = Path(path or DEFAULT_CONFIG_NAME)
|
|
61
|
+
if not p.exists():
|
|
62
|
+
raise FileNotFoundError(
|
|
63
|
+
f"Config not found: {p}\n"
|
|
64
|
+
"Copy config.example.toml -> config.toml and edit it first: "
|
|
65
|
+
"`coinwatch init` can do that for you."
|
|
66
|
+
)
|
|
67
|
+
with p.open("rb") as fh:
|
|
68
|
+
data = tomllib.load(fh)
|
|
69
|
+
return Config(
|
|
70
|
+
watchlist=data.get("watchlist") or {},
|
|
71
|
+
thresholds=data.get("thresholds") or {},
|
|
72
|
+
notify=data.get("notify") or {},
|
|
73
|
+
web=data.get("web") or {},
|
|
74
|
+
schedule=data.get("schedule") or {},
|
|
75
|
+
raw=data,
|
|
76
|
+
)
|
coinwatch/market.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Market data sources registry and failover-capable snapshot builder."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .sources.binance import Binance
|
|
6
|
+
from .sources.coingecko import CoinGecko
|
|
7
|
+
from .sources.okx import OKX
|
|
8
|
+
from .sources.base import Quote, Source
|
|
9
|
+
|
|
10
|
+
__all__ = ["Market", "Quote", "Source", "CoinGecko", "Binance", "OKX"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Market:
|
|
14
|
+
"""Aggregates several sources, falling back between them per coin.
|
|
15
|
+
|
|
16
|
+
Sources are tried in order. A coin is satisfied by the first source that
|
|
17
|
+
returns it, so a single broken provider never takes down the whole run.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, sources: list[Source] | None = None) -> None:
|
|
21
|
+
self.sources: list[Source] = sources or [CoinGecko(), Binance(), OKX()]
|
|
22
|
+
|
|
23
|
+
def snapshot(self, coin_ids: list[str], symbols: dict[str, str]) -> dict[str, Quote]:
|
|
24
|
+
"""Fetch every requested coin, using whichever source can answer."""
|
|
25
|
+
results: dict[str, Quote] = {}
|
|
26
|
+
pending = list(coin_ids)
|
|
27
|
+
failures: dict[str, str] = {}
|
|
28
|
+
|
|
29
|
+
for source in self.sources:
|
|
30
|
+
if not pending:
|
|
31
|
+
break
|
|
32
|
+
try:
|
|
33
|
+
got = source.fetch(pending, symbols)
|
|
34
|
+
except RuntimeError as exc:
|
|
35
|
+
failures[source.name] = str(exc)
|
|
36
|
+
continue
|
|
37
|
+
for cid in list(pending):
|
|
38
|
+
quote = got.get(cid)
|
|
39
|
+
if quote is not None:
|
|
40
|
+
results[cid] = quote
|
|
41
|
+
pending.remove(cid)
|
|
42
|
+
|
|
43
|
+
self.last_failures = failures
|
|
44
|
+
return results
|
coinwatch/notifier.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Notification channels: log, Telegram, Discord, Email."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import smtplib
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
from email.message import EmailMessage
|
|
10
|
+
|
|
11
|
+
from .config import Config
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def notify(config: Config, message: str) -> None:
|
|
15
|
+
"""Fan a rendered report out to every enabled channel."""
|
|
16
|
+
message = message or ""
|
|
17
|
+
settings = config.notify
|
|
18
|
+
|
|
19
|
+
if str(settings.get("log", True)).lower() not in ("false", "0", "no"):
|
|
20
|
+
if message.strip():
|
|
21
|
+
print(message)
|
|
22
|
+
|
|
23
|
+
# An empty report means "nothing happened" — never spam external channels.
|
|
24
|
+
if not message.strip():
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
_telegram(settings, message)
|
|
28
|
+
_discord(settings, message)
|
|
29
|
+
_email(settings, message)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _post_json(url: str, payload: dict) -> None:
|
|
33
|
+
body = json.dumps(payload).encode("utf-8")
|
|
34
|
+
req = urllib.request.Request(
|
|
35
|
+
url, data=body, headers={"Content-Type": "application/json"}
|
|
36
|
+
)
|
|
37
|
+
try:
|
|
38
|
+
with urllib.request.urlopen(req, timeout=10):
|
|
39
|
+
pass
|
|
40
|
+
except Exception: # noqa: BLE001 - one bad channel must not break the run
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _telegram(settings: dict, message: str) -> None:
|
|
45
|
+
token = settings.get("telegram_token")
|
|
46
|
+
chat_id = settings.get("telegram_chat_id")
|
|
47
|
+
if not token or not chat_id:
|
|
48
|
+
return
|
|
49
|
+
_post_json(
|
|
50
|
+
f"https://api.telegram.org/bot{token}/sendMessage",
|
|
51
|
+
{"chat_id": chat_id, "text": message},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _discord(settings: dict, message: str) -> None:
|
|
56
|
+
webhook = settings.get("discord_webhook")
|
|
57
|
+
if not webhook:
|
|
58
|
+
return
|
|
59
|
+
# Discord hard-caps embeds/content at 2000 chars.
|
|
60
|
+
_post_json(webhook, {"content": message[:1900]})
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _email(settings: dict, message: str) -> None:
|
|
64
|
+
host = settings.get("email_smtp_host")
|
|
65
|
+
recipient = settings.get("email_to")
|
|
66
|
+
if not host or not recipient:
|
|
67
|
+
return
|
|
68
|
+
try:
|
|
69
|
+
msg = EmailMessage()
|
|
70
|
+
msg["Subject"] = "CoinWatch Alert"
|
|
71
|
+
msg["From"] = settings.get("email_smtp_user") or "coinwatch@localhost"
|
|
72
|
+
msg["To"] = recipient
|
|
73
|
+
msg.set_content(message)
|
|
74
|
+
|
|
75
|
+
with smtplib.SMTP(host, int(settings.get("email_smtp_port", 587))) as server:
|
|
76
|
+
server.starttls()
|
|
77
|
+
user = settings.get("email_smtp_user")
|
|
78
|
+
if user:
|
|
79
|
+
server.login(user, settings.get("email_smtp_pass", ""))
|
|
80
|
+
server.send_message(msg)
|
|
81
|
+
except Exception: # noqa: BLE001
|
|
82
|
+
pass
|
coinwatch/report.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Text and JSON report rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .sources.base import Quote
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def render_text(quotes: dict[str, Quote], alerts: list[str]) -> str:
|
|
9
|
+
lines = ["", "=== CoinWatch 行情快照 ===", ""]
|
|
10
|
+
if quotes:
|
|
11
|
+
lines.append(f"{'SYMBOL':<8}{'PRICE':>14}{'24H':>10} SOURCE")
|
|
12
|
+
lines.append("-" * 46)
|
|
13
|
+
for quote in quotes.values():
|
|
14
|
+
change = f"{quote.change_24h_pct:+.2f}%" if quote.change_24h_pct is not None else "n/a"
|
|
15
|
+
lines.append(
|
|
16
|
+
f"{quote.symbol:<8}{'$' + format(quote.price, ',.2f'):>14}"
|
|
17
|
+
f"{change:>10} {quote.source}"
|
|
18
|
+
)
|
|
19
|
+
else:
|
|
20
|
+
lines.append("(无数据 — 所有行情源均不可用)")
|
|
21
|
+
lines.append("")
|
|
22
|
+
|
|
23
|
+
if alerts:
|
|
24
|
+
lines.append("--- 告警 ---")
|
|
25
|
+
lines.extend(f"* {a}" for a in alerts)
|
|
26
|
+
else:
|
|
27
|
+
lines.append("--- 无告警 ---")
|
|
28
|
+
lines.append("")
|
|
29
|
+
return "\n".join(lines)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render_json(quotes: dict[str, Quote], alerts: list[str]) -> dict:
|
|
33
|
+
return {
|
|
34
|
+
"quotes": {cid: q.as_dict() for cid, q in quotes.items()},
|
|
35
|
+
"alerts": alerts,
|
|
36
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Base abstractions for market data sources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
USER_AGENT = "CoinWatch/0.1 (+https://github.com/AlfredoJamesPacino/coinwatch)"
|
|
11
|
+
DEFAULT_TIMEOUT = 10.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Quote:
|
|
15
|
+
"""A normalized single-coin quote, independent of which source produced it."""
|
|
16
|
+
|
|
17
|
+
__slots__ = ("coin_id", "symbol", "price", "change_24h_pct", "volume_24h", "source")
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
coin_id: str,
|
|
22
|
+
symbol: str,
|
|
23
|
+
price: float,
|
|
24
|
+
change_24h_pct: float | None = None,
|
|
25
|
+
volume_24h: float | None = None,
|
|
26
|
+
source: str = "",
|
|
27
|
+
) -> None:
|
|
28
|
+
self.coin_id = coin_id
|
|
29
|
+
self.symbol = symbol
|
|
30
|
+
self.price = price
|
|
31
|
+
self.change_24h_pct = change_24h_pct
|
|
32
|
+
self.volume_24h = volume_24h
|
|
33
|
+
self.source = source
|
|
34
|
+
|
|
35
|
+
def as_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"coin_id": self.coin_id,
|
|
38
|
+
"symbol": self.symbol,
|
|
39
|
+
"price": self.price,
|
|
40
|
+
"change_24h_pct": self.change_24h_pct,
|
|
41
|
+
"volume_24h": self.volume_24h,
|
|
42
|
+
"source": self.source,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
46
|
+
return f"<Quote {self.symbol} ${self.price} via {self.source}>"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class SourceError(RuntimeError):
|
|
50
|
+
"""Raised when a source cannot be reached or returns unparseable data."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Source:
|
|
54
|
+
"""Base class for a market data source."""
|
|
55
|
+
|
|
56
|
+
name = "base"
|
|
57
|
+
|
|
58
|
+
def fetch(self, coin_ids: list[str], symbols: dict[str, str]) -> dict[str, Quote]:
|
|
59
|
+
"""Return quotes for as many of ``coin_ids`` as this source can provide."""
|
|
60
|
+
raise NotImplementedError
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def _get_json(url: str, timeout: float = DEFAULT_TIMEOUT) -> Any:
|
|
64
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
65
|
+
try:
|
|
66
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
67
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
68
|
+
except (urllib.error.URLError, TimeoutError, ValueError) as exc:
|
|
69
|
+
raise SourceError(f"{url} -> {exc}") from exc
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Binance public REST API source (no API key required)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.parse
|
|
7
|
+
|
|
8
|
+
from .base import Quote, Source
|
|
9
|
+
|
|
10
|
+
_TIMEOUT = 10.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Binance(Source):
|
|
14
|
+
name = "binance"
|
|
15
|
+
BASE = "https://api.binance.com/api/v3/ticker/24hr"
|
|
16
|
+
|
|
17
|
+
def fetch(self, coin_ids: list[str], symbols: dict[str, str]) -> dict[str, Quote]:
|
|
18
|
+
if not coin_ids:
|
|
19
|
+
return {}
|
|
20
|
+
# Mapbase symbol -> coin id, restricted to the coins actually requested.
|
|
21
|
+
wanted = {sym.upper(): cid for cid, sym in symbols.items() if cid in coin_ids}
|
|
22
|
+
if not wanted:
|
|
23
|
+
return {}
|
|
24
|
+
|
|
25
|
+
pairs = [f"{base}USDT" for base in wanted]
|
|
26
|
+
# Compact separators keep spaces (which urlencode turns into "+") out of the JSON.
|
|
27
|
+
query = urllib.parse.urlencode({"symbols": json.dumps(pairs, separators=(",", ":"))})
|
|
28
|
+
data = self._get_json(f"{self.BASE}?{query}")
|
|
29
|
+
|
|
30
|
+
# Binance answers API errors with an object instead of an array.
|
|
31
|
+
if isinstance(data, dict):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
out: dict[str, Quote] = {}
|
|
35
|
+
for row in data:
|
|
36
|
+
pair = row.get("symbol", "")
|
|
37
|
+
if not pair.endswith("USDT"):
|
|
38
|
+
continue
|
|
39
|
+
cid = wanted.get(pair[: -len("USDT")])
|
|
40
|
+
if cid is None:
|
|
41
|
+
continue
|
|
42
|
+
try:
|
|
43
|
+
out[cid] = Quote(
|
|
44
|
+
coin_id=cid,
|
|
45
|
+
symbol=symbols.get(cid, pair[: -len("USDT")]),
|
|
46
|
+
price=float(row["lastPrice"]),
|
|
47
|
+
change_24h_pct=_opt_float(row.get("priceChangePercent")),
|
|
48
|
+
volume_24h=_opt_float(row.get("quoteVolume")),
|
|
49
|
+
source=self.name,
|
|
50
|
+
)
|
|
51
|
+
except KeyError:
|
|
52
|
+
continue
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _opt_float(value) -> float | None:
|
|
57
|
+
try:
|
|
58
|
+
return float(value)
|
|
59
|
+
except (TypeError, ValueError):
|
|
60
|
+
return None
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""CoinGecko public API source (no API key required)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Quote, Source
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CoinGecko(Source):
|
|
9
|
+
name = "coingecko"
|
|
10
|
+
BASE = "https://api.coingecko.com/api/v3/simple/price"
|
|
11
|
+
|
|
12
|
+
def fetch(self, coin_ids: list[str], symbols: dict[str, str]) -> dict[str, Quote]:
|
|
13
|
+
if not coin_ids:
|
|
14
|
+
return {}
|
|
15
|
+
ids = ",".join(coin_ids)
|
|
16
|
+
url = (
|
|
17
|
+
f"{self.BASE}?ids={ids}&vs_currencies=usd"
|
|
18
|
+
"&include_24hr_change=true&include_24hr_vol=true"
|
|
19
|
+
)
|
|
20
|
+
data = self._get_json(url)
|
|
21
|
+
|
|
22
|
+
out: dict[str, Quote] = {}
|
|
23
|
+
for cid in coin_ids:
|
|
24
|
+
row = data.get(cid) if isinstance(data, dict) else None
|
|
25
|
+
if not row or "usd" not in row:
|
|
26
|
+
continue
|
|
27
|
+
out[cid] = Quote(
|
|
28
|
+
coin_id=cid,
|
|
29
|
+
symbol=symbols.get(cid, cid.upper()),
|
|
30
|
+
price=float(row["usd"]),
|
|
31
|
+
change_24h_pct=_opt_float(row.get("usd_24h_change")),
|
|
32
|
+
volume_24h=_opt_float(row.get("usd_24h_vol")),
|
|
33
|
+
source=self.name,
|
|
34
|
+
)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _opt_float(value) -> float | None:
|
|
39
|
+
try:
|
|
40
|
+
return float(value)
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
return None
|
coinwatch/sources/okx.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""OKX public REST API source (no API key required)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Quote, Source
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OKX(Source):
|
|
9
|
+
name = "okx"
|
|
10
|
+
BASE = "https://www.okx.com/api/v5/market/tickers?instType=SPOT"
|
|
11
|
+
|
|
12
|
+
def fetch(self, coin_ids: list[str], symbols: dict[str, str]) -> dict[str, Quote]:
|
|
13
|
+
if not coin_ids:
|
|
14
|
+
return {}
|
|
15
|
+
wanted = {f"{sym.upper()}-USDT": cid for cid, sym in symbols.items() if cid in coin_ids}
|
|
16
|
+
if not wanted:
|
|
17
|
+
return {}
|
|
18
|
+
|
|
19
|
+
data = self._get_json(self.BASE)
|
|
20
|
+
rows = data.get("data") if isinstance(data, dict) else None
|
|
21
|
+
if not rows:
|
|
22
|
+
return {}
|
|
23
|
+
|
|
24
|
+
out: dict[str, Quote] = {}
|
|
25
|
+
for row in rows:
|
|
26
|
+
cid = wanted.get(row.get("instId", ""))
|
|
27
|
+
if cid is None:
|
|
28
|
+
continue
|
|
29
|
+
try:
|
|
30
|
+
last = float(row["last"])
|
|
31
|
+
open24h = float(row.get("open24h", last))
|
|
32
|
+
change = ((last - open24h) / open24h * 100) if open24h else None
|
|
33
|
+
out[cid] = Quote(
|
|
34
|
+
coin_id=cid,
|
|
35
|
+
symbol=symbols.get(cid, row["instId"].split("-")[0]),
|
|
36
|
+
price=last,
|
|
37
|
+
change_24h_pct=change,
|
|
38
|
+
volume_24h=_opt_float(row.get("quoteVol24h")),
|
|
39
|
+
source=self.name,
|
|
40
|
+
)
|
|
41
|
+
except (KeyError, ValueError, ZeroDivisionError):
|
|
42
|
+
continue
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _opt_float(value) -> float | None:
|
|
47
|
+
try:
|
|
48
|
+
return float(value)
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
return None
|
coinwatch/web.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Minimal local dashboard, powered by the standard library only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
|
|
7
|
+
from .alert import evaluate
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .market import Market
|
|
10
|
+
from .sources.base import Quote
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_page(quotes: dict[str, Quote], alerts: list[str]) -> str:
|
|
14
|
+
rows = ""
|
|
15
|
+
for quote in quotes.values():
|
|
16
|
+
change = f"{quote.change_24h_pct:+.2f}%" if quote.change_24h_pct is not None else "n/a"
|
|
17
|
+
color = "#ffffff"
|
|
18
|
+
if quote.change_24h_pct is not None:
|
|
19
|
+
# Chinese market convention: up = red, down = green.
|
|
20
|
+
color = "#ff4d4f" if quote.change_24h_pct >= 0 else "#0ecb81"
|
|
21
|
+
rows += (
|
|
22
|
+
f"<tr><td><b>{quote.symbol}</b></td>"
|
|
23
|
+
f"<td>${quote.price:,.2f}</td>"
|
|
24
|
+
f'<td style="color:{color}">{change}</td>'
|
|
25
|
+
f"<td>{quote.source}</td></tr>"
|
|
26
|
+
)
|
|
27
|
+
if not rows:
|
|
28
|
+
rows = '<tr><td colspan="4">无数据 — 所有行情源均不可用</td></tr>'
|
|
29
|
+
|
|
30
|
+
alert_html = (
|
|
31
|
+
"<br>".join(f"• {a}" for a in alerts) if alerts else "当前无告警"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return f"""<!doctype html>
|
|
35
|
+
<html><head><meta charset="utf-8">
|
|
36
|
+
<meta http-equiv="refresh" content="60">
|
|
37
|
+
<title>CoinWatch</title>
|
|
38
|
+
<style>
|
|
39
|
+
body {{ font-family: -apple-system, "Segoe UI", sans-serif; background:#0f1115;
|
|
40
|
+
color:#e6e6e6; padding:2rem; max-width:900px; margin:0 auto; }}
|
|
41
|
+
h1 {{ color:#f7931a; margin-bottom:.2rem; }}
|
|
42
|
+
table {{ border-collapse:collapse; width:100%; margin-top:1rem; }}
|
|
43
|
+
th, td {{ padding:.6rem .8rem; border-bottom:1px solid #2a2f3a; text-align:left; }}
|
|
44
|
+
th {{ color:#8b93a7; font-weight:600; }}
|
|
45
|
+
.card {{ background:#161a22; border:1px solid #232833; border-radius:10px;
|
|
46
|
+
padding:1rem 1.2rem; margin-top:1.5rem; }}
|
|
47
|
+
.alert {{ color:#ff6b6b; }}
|
|
48
|
+
small {{ color:#6b7280; }}
|
|
49
|
+
</style></head>
|
|
50
|
+
<body>
|
|
51
|
+
<h1>CoinWatch</h1>
|
|
52
|
+
<small>本地优先 · 数据来源:CoinGecko / Binance / OKX 公开 API · 自动刷新 60s</small>
|
|
53
|
+
<table>
|
|
54
|
+
<tr><th>币种</th><th>价格</th><th>24h</th><th>数据源</th></tr>
|
|
55
|
+
{rows}
|
|
56
|
+
</table>
|
|
57
|
+
<div class="card"><b>告警</b><div class="alert">{alert_html}</div></div>
|
|
58
|
+
<p class="card small">本项目仅做行情监控与告警,不构成任何投资建议。加密货币市场高风险,请自行判断并控制风险。</p>
|
|
59
|
+
</body></html>"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def make_handler(config: Config, market: Market):
|
|
63
|
+
class Handler(BaseHTTPRequestHandler):
|
|
64
|
+
def do_GET(self) -> None: # noqa: N802 - stdlib naming
|
|
65
|
+
quotes = market.snapshot(config.coin_ids, config.watchlist)
|
|
66
|
+
alerts = evaluate(config, quotes)
|
|
67
|
+
body = build_page(quotes, alerts).encode("utf-8")
|
|
68
|
+
self.send_response(200)
|
|
69
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
70
|
+
self.send_header("Content-Length", str(len(body)))
|
|
71
|
+
self.end_headers()
|
|
72
|
+
self.wfile.write(body)
|
|
73
|
+
|
|
74
|
+
def log_message(self, *args, **kwargs) -> None:
|
|
75
|
+
pass # keep the console clean; alerts already go through notify()
|
|
76
|
+
|
|
77
|
+
return Handler
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_web(config: Config, market: Market) -> None:
|
|
81
|
+
host = config.web.get("host", "127.0.0.1")
|
|
82
|
+
port = int(config.web.get("port", 8080))
|
|
83
|
+
server = ThreadingHTTPServer((host, port), make_handler(config, market))
|
|
84
|
+
print(f"CoinWatch dashboard: http://{host}:{port} (Ctrl+C 退出)")
|
|
85
|
+
try:
|
|
86
|
+
server.serve_forever()
|
|
87
|
+
except KeyboardInterrupt:
|
|
88
|
+
print("\n已停止。")
|
|
89
|
+
finally:
|
|
90
|
+
server.server_close()
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: coinwatch-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local-first crypto market monitor & alert engine. Multi-source failover, zero third-party dependencies.
|
|
5
|
+
Project-URL: Homepage, https://github.com/AlfredoJamesPacino/coinwatch
|
|
6
|
+
Project-URL: Repository, https://github.com/AlfredoJamesPacino/coinwatch
|
|
7
|
+
Project-URL: Issues, https://github.com/AlfredoJamesPacino/coinwatch/issues
|
|
8
|
+
Author: MT
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: alerting,bitcoin,cli,crypto,monitoring,price-tracker
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# CoinWatch
|
|
27
|
+
|
|
28
|
+
[](https://github.com/AlfredoJamesPacino/coinwatch/actions/workflows/ci.yml)
|
|
29
|
+
[](https://opensource.org/licenses/MIT)
|
|
30
|
+
[](https://www.python.org/downloads/)
|
|
31
|
+
|
|
32
|
+
**本地优先的加密货币行情监控与告警引擎。**
|
|
33
|
+
|
|
34
|
+
多数据源自动故障切换,标准库零依赖,调度无关(cron / Windows 任务计划 / WorkBuddy 自动化都能驱动)。
|
|
35
|
+
|
|
36
|
+
> CoinWatch 只做**数据监控与告警**,不提供任何投资建议。加密货币市场高风险,请自行判断。
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## 为什么要有它
|
|
41
|
+
|
|
42
|
+
盯盘的实际痛点,其实就三个:
|
|
43
|
+
|
|
44
|
+
1. **单一数据源不可靠** —— CoinGecko 免费额度会限流,Binance 在某些地区被墙,OKX 偶尔抖动。挂了就什么都看不到。
|
|
45
|
+
2. **不想一直开着交易所 App** —— 只想在该动的时候被叫一声。
|
|
46
|
+
3. **担心凭证乱飞** —— 没人希望自己的密钥躺在某个不知名软件的数据库里。
|
|
47
|
+
|
|
48
|
+
CoinWatch 的设计回答就是:**多源兜底 + 阈值告警 + 完全不需要交易所密钥**(只用公开行情接口)。
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 特性
|
|
53
|
+
|
|
54
|
+
- 🔁 **多源故障切换**:CoinGecko → Binance → OKX 逐级兜底,按币种补齐,单源挂掉不影响整体
|
|
55
|
+
- 🔔 **多类告警**:24h 涨跌幅、上下方价格突破、成交量异动(滚动基线)
|
|
56
|
+
- 📡 **多渠道通知**:终端日志 / Telegram / Discord / Email(SMTP)
|
|
57
|
+
- 🌐 **本地面板**:内置 Web Dashboard(每 60s 自动刷新),只监听 `127.0.0.1`
|
|
58
|
+
- 📦 **零第三方依赖**:核心只用 Python 标准库,克隆下来就能跑
|
|
59
|
+
- 🕒 **调度无关**:不自带定时器,交给 cron / 任务计划 / WorkBuddy
|
|
60
|
+
- 🧩 **易扩展**:新增数据源只需实现 `Source.fetch()`
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 快速开始
|
|
65
|
+
|
|
66
|
+
要求 **Python 3.11+**(用到标准库 `tomllib`)。
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
git clone https://github.com/AlfredoJamesPacino/coinwatch.git
|
|
70
|
+
cd coinwatch
|
|
71
|
+
|
|
72
|
+
# 安装(可选;不装也能跑)
|
|
73
|
+
pip install -e .
|
|
74
|
+
|
|
75
|
+
# 确认版本
|
|
76
|
+
coinwatch --version
|
|
77
|
+
|
|
78
|
+
# 生成配置
|
|
79
|
+
coinwatch init # 从 config.example.toml 复制出 config.toml
|
|
80
|
+
# 编辑 config.toml,设置自选币种、阈值、通知渠道
|
|
81
|
+
|
|
82
|
+
# 跑一次
|
|
83
|
+
coinwatch check
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
不想安装的话,直接用模块方式运行:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
PYTHONPATH=src python -m coinwatch.cli check
|
|
90
|
+
PowerShell: $env:PYTHONPATH="src"; python -m coinwatch.cli check
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
首屏输出类似:
|
|
94
|
+
|
|
95
|
+
```
|
|
96
|
+
=== CoinWatch 行情快照 ===
|
|
97
|
+
|
|
98
|
+
SYMBOL PRICE 24H SOURCE
|
|
99
|
+
----------------------------------------------
|
|
100
|
+
BTC $67,432.10 +2.31% coingecko
|
|
101
|
+
ETH $3,210.55 -1.04% binance
|
|
102
|
+
SOL $178.22 +6.77% coingecko
|
|
103
|
+
|
|
104
|
+
无告警。
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 配置
|
|
110
|
+
|
|
111
|
+
编辑 `config.toml`(**该文件已被 `.gitignore` 忽略,别提交**):
|
|
112
|
+
|
|
113
|
+
```toml
|
|
114
|
+
[watchlist]
|
|
115
|
+
bitcoin = "BTC" # 左边是 CoinGecko coin id(小写)
|
|
116
|
+
ethereum = "ETH"
|
|
117
|
+
solana = "SOL"
|
|
118
|
+
|
|
119
|
+
[thresholds]
|
|
120
|
+
change_pct_alert = 5.0 # |24h 涨跌| >= 5% 就告警,0 关闭
|
|
121
|
+
volume_spike_pct = 0.0 # 成交量异动阈值,0 关闭
|
|
122
|
+
price_above = { "bitcoin" = 200000 } # 突破上方
|
|
123
|
+
price_below = { "ethereum" = 500 } # 跌破下方
|
|
124
|
+
|
|
125
|
+
[notify]
|
|
126
|
+
log = true
|
|
127
|
+
telegram_token = "" # 从 @BotFather 拿
|
|
128
|
+
telegram_chat_id = ""
|
|
129
|
+
discord_webhook = ""
|
|
130
|
+
email_smtp_host = "" # 留空即关闭邮件通道
|
|
131
|
+
email_smtp_user = ""
|
|
132
|
+
email_smtp_pass = "" # 建议用应用专用密码
|
|
133
|
+
email_to = ""
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
**安全注意**: bot token 和 SMTP 密码只存在本地 `config.toml` 里,**从不随代码提交**。仓库只提交脱敏的 `config.example.toml`。
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## 命令
|
|
141
|
+
|
|
142
|
+
| 命令 | 作用 |
|
|
143
|
+
| --- | --- |
|
|
144
|
+
| `coinwatch check` | 拉一次快照,评估阈值,有告警就推送 |
|
|
145
|
+
| `coinwatch list` | 只列出自选行情,不触发告警 |
|
|
146
|
+
| `coinwatch watch [--interval N]` | 循环监控,默认读配置里的 `interval_seconds` |
|
|
147
|
+
| `coinwatch web` | 启动本地面板,默认 `http://127.0.0.1:8080` |
|
|
148
|
+
| `coinwatch init` | 从示例生成 `config.toml` |
|
|
149
|
+
|
|
150
|
+
所有命令都支持 `--config <path>` 指定配置文件位置;`coinwatch --version` 输出版本号。
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 网络与区域可用性
|
|
155
|
+
|
|
156
|
+
CoinWatch 只用公开行情接口,但这些接口在**部分地区/网络下可能直连不通**(Binance、OKX、CoinGecko 均有被拦或限流的情况)。
|
|
157
|
+
|
|
158
|
+
遇到这种情况时,CoinWatch **不会假装没数据**,而会把失败原因打出来:
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
行情源不可用:
|
|
162
|
+
! coingecko: https://api.coingecko.com/... -> Tunnel connection failed: 502 Bad Gateway
|
|
163
|
+
! binance: https://api.binance.com/... -> Tunnel connection failed: 502 Bad Gateway
|
|
164
|
+
-> 常见原因:本机网络到该 API 不通(部分地区需代理)、或被限流。
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
处理方式:给本机配好代理即可,`urllib` 会自动读取 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
export HTTPS_PROXY=http://127.0.0.1:7890
|
|
171
|
+
coinwatch check
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
由于 3 个源会互相兜底,即使只有一个能通,快照依然完整。
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## 定时执行
|
|
179
|
+
|
|
180
|
+
CoinWatch **不自带守护进程**,这是刻意的——交给更专业的调度器,也更省资源。
|
|
181
|
+
|
|
182
|
+
**Linux / macOS(cron,每 5 分钟):**
|
|
183
|
+
|
|
184
|
+
```cron
|
|
185
|
+
*/5 * * * * cd /path/to/coinwatch && python -m coinwatch.cli check >> coinwatch.log 2>&1
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Windows(任务计划程序):**
|
|
189
|
+
|
|
190
|
+
```powershell
|
|
191
|
+
$action = New-ScheduledTaskAction -Execute "python" `
|
|
192
|
+
-Argument "-m coinwatch.cli check" -WorkingDirectory "C:\path\to\coinwatch"
|
|
193
|
+
$trigger = New-ScheduledTaskTrigger -RepetitionInterval (New-TimeSpan -Minutes 5) -Once -At (Get-Date)
|
|
194
|
+
Register-ScheduledTask -TaskName "CoinWatch" -Action $action -Trigger $trigger
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
**WorkBuddy 自动化:** 创建一条定时任务,指令为「运行 `coinwatch check` 」,即可把告警接进 AI 工作流。
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## 架构
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
src/coinwatch/
|
|
205
|
+
├── config.py # TOML 加载与阈值访问
|
|
206
|
+
├── market.py # Market:多源聚合 + 按币种 failover
|
|
207
|
+
├── alert.py # 阈值评估,成交量基线持久化
|
|
208
|
+
├── notifier.py # log / Telegram / Discord / Email
|
|
209
|
+
├── report.py # 文本 & JSON 渲染
|
|
210
|
+
├── web.py # 标准库 http.server 面板
|
|
211
|
+
├── cli.py # argparse 入口
|
|
212
|
+
└── sources/
|
|
213
|
+
├── base.py # Quote / Source 抽象
|
|
214
|
+
├── coingecko.py
|
|
215
|
+
├── binance.py
|
|
216
|
+
└── okx.py
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
数据流:
|
|
220
|
+
|
|
221
|
+
```
|
|
222
|
+
config.toml → Market.snapshot() → alert.evaluate() → notifier.notify()
|
|
223
|
+
↑
|
|
224
|
+
CoinGecko → Binance → OKX(逐级兜底)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**新增一个数据源**:继承 `Source`,实现 `fetch(coin_ids, symbols) -> dict[str, Quote]`,然后丢进 `Market(sources=[...])`。解析逻辑请配一个 `monkeypatch` 单测,参考 `tests/test_sources.py`。
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
|
|
231
|
+
## 开发
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
pip install -e ".[dev]"
|
|
235
|
+
pytest -q
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
测试不访问网络——用 `monkeypatch` 打桩 `_get_json` 来验证解析逻辑,所以 CI 里跑得又快又稳。
|
|
239
|
+
|
|
240
|
+
---
|
|
241
|
+
|
|
242
|
+
## 免责声明
|
|
243
|
+
|
|
244
|
+
本项目**仅用于行情监控与告警**,不提供任何投资建议、买卖信号或收益承诺。所有数据来自第三方公开 API,可能延迟或不准确,请以交易所实际成交为准。加密货币市场波动剧烈、风险极高,请自行判断并控制风险。
|
|
245
|
+
|
|
246
|
+
---
|
|
247
|
+
|
|
248
|
+
## License
|
|
249
|
+
|
|
250
|
+
[MIT](LICENSE) © 2026 MT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
coinwatch/__init__.py,sha256=DLLcAZ7ynmSk5HovyQ8fLnxB9372-MqPMYhO7CASnTg,119
|
|
2
|
+
coinwatch/alert.py,sha256=CxhilXzAhVChJSdqZwIDJrxbb5umoU_h4kVm1sxByN4,2813
|
|
3
|
+
coinwatch/cli.py,sha256=sUHjIWWlGPDmn8IxsLZ5amaz4F81YCWW6SUnAf3B6l0,4555
|
|
4
|
+
coinwatch/config.py,sha256=Jdww8Ympr3EmMVfSVdqwd5q-YRDaV4_kPGVuZbAkEig,2329
|
|
5
|
+
coinwatch/market.py,sha256=EU7CgILZC4feRiw16GJmTkPSfg2cz0mzWFCTZSyoQtI,1534
|
|
6
|
+
coinwatch/notifier.py,sha256=k9MGOBAfy_gD7mot7m2EjMBpbbFQfntpGoD72j4QRvY,2437
|
|
7
|
+
coinwatch/report.py,sha256=Jc_D8sEUViXbwU3lMkecuohAVmmACcITpGBiKu32jbc,1140
|
|
8
|
+
coinwatch/web.py,sha256=X_KxclSnEvnM9jD8WkI7s1sNEN0qXKxsAVQBS3fKoQU,3567
|
|
9
|
+
coinwatch/sources/__init__.py,sha256=CfbWgiJVQzXsciQ33C1-xsgZkjALbYJ99hVhsy0AWZU,248
|
|
10
|
+
coinwatch/sources/base.py,sha256=xW0lfNeE9hgWdGqlfPjaSyo9-WZWmZiKoLZjzbx-JM0,2167
|
|
11
|
+
coinwatch/sources/binance.py,sha256=dFTHVOoIl6yL9S2UeknXx5Jm2GswzAALVnxNefeF9B8,1934
|
|
12
|
+
coinwatch/sources/coingecko.py,sha256=gM9Dx1bHXtn1Q0U9r_TczusQ-5zICVXDZr6sggBgJCc,1271
|
|
13
|
+
coinwatch/sources/okx.py,sha256=r-XlPUgcKg-MYZtxh8u9_9kS9MW77b9avmihh3HH4qU,1605
|
|
14
|
+
coinwatch_cli-0.1.0.dist-info/METADATA,sha256=kv4qCx1qY97LRUjrpZBYJ_VhQErtA1K2EKEiYjqbUDM,8593
|
|
15
|
+
coinwatch_cli-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
16
|
+
coinwatch_cli-0.1.0.dist-info/entry_points.txt,sha256=h9uJDTwXIp-pixIGisxsANcuTqOrAn_orTBdbTRqjlo,49
|
|
17
|
+
coinwatch_cli-0.1.0.dist-info/licenses/LICENSE,sha256=p0b81YSNTvs_zRPBcSf2vGoePFdGF16ul0nxKygZJNo,1080
|
|
18
|
+
coinwatch_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MT
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|