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 ADDED
@@ -0,0 +1,4 @@
1
+ """CoinWatch — local-first crypto market monitor & alert engine."""
2
+
3
+ __version__ = "0.1.0"
4
+ __all__ = ["__version__"]
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,8 @@
1
+ """Market data source implementations."""
2
+
3
+ from .base import Quote, Source, SourceError
4
+ from .binance import Binance
5
+ from .coingecko import CoinGecko
6
+ from .okx import OKX
7
+
8
+ __all__ = ["Quote", "Source", "SourceError", "CoinGecko", "Binance", "OKX"]
@@ -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
@@ -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"&bull; {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
+ [![CI](https://github.com/AlfredoJamesPacino/coinwatch/actions/workflows/ci.yml/badge.svg)](https://github.com/AlfredoJamesPacino/coinwatch/actions/workflows/ci.yml)
29
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
30
+ [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ coinwatch = coinwatch.cli:main
@@ -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.