easyquotes 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.
easyquote/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """EasyQuote Python SDK — Global market data in a few lines of code."""
2
+ from ._client import EasyQuoteClient
3
+ from ._exceptions import APIError, AuthError, EasyQuoteError, NotFoundError, RateLimitError
4
+ from ._models import Company, Executive, FinancialReport, FundFlow, KLine, MinuteBar, Quote
5
+
6
+ __all__ = [
7
+ "EasyQuoteClient",
8
+ # Models
9
+ "Quote",
10
+ "KLine",
11
+ "MinuteBar",
12
+ "FundFlow",
13
+ "Company",
14
+ "Executive",
15
+ "FinancialReport",
16
+ # Exceptions
17
+ "EasyQuoteError",
18
+ "AuthError",
19
+ "RateLimitError",
20
+ "NotFoundError",
21
+ "APIError",
22
+ ]
23
+
24
+ __version__ = "0.1.0"
easyquote/_client.py ADDED
@@ -0,0 +1,469 @@
1
+ """EasyQuote synchronous HTTP client."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from typing import Any
6
+ from urllib.parse import urlencode
7
+ from urllib.request import Request, urlopen
8
+ import json as _json
9
+
10
+ from ._exceptions import APIError, AuthError, NotFoundError, RateLimitError
11
+ from ._models import Company, Executive, FinancialReport, FundFlow, KLine, MinuteBar, Quote
12
+ from ._parse import (
13
+ parse_company,
14
+ parse_executive,
15
+ parse_financial_report,
16
+ parse_kline,
17
+ parse_minute,
18
+ parse_quote,
19
+ split_symbol,
20
+ )
21
+ from ._ws import RealtimeStream
22
+
23
+ _DEFAULT_BASE_URL = "https://api.easyquote.io"
24
+
25
+
26
+ class EasyQuoteClient:
27
+ """EasyQuote API client.
28
+
29
+ Args:
30
+ api_key: Your API key (``eq_`` prefix). Falls back to the
31
+ ``EASYQUOTE_API_KEY`` environment variable.
32
+ base_url: Override the API base URL (useful for self-hosted deployments).
33
+ timeout: HTTP request timeout in seconds (default 30).
34
+
35
+ Example::
36
+
37
+ from easyquote import EasyQuoteClient
38
+
39
+ client = EasyQuoteClient(api_key="eq_xxx")
40
+
41
+ quote = client.get_quote("600519.SH")
42
+ print(f"{quote.name}: {quote.price} ({quote.change_pct:+.2f}%)")
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ api_key: str | None = None,
48
+ base_url: str = _DEFAULT_BASE_URL,
49
+ timeout: int = 30,
50
+ ) -> None:
51
+ self._api_key = api_key or os.environ.get("EASYQUOTE_API_KEY") or ""
52
+ if not self._api_key:
53
+ raise AuthError(
54
+ "No API key provided. Pass api_key= or set EASYQUOTE_API_KEY env var."
55
+ )
56
+ self._base_url = base_url.rstrip("/")
57
+ self._timeout = timeout
58
+
59
+ # ------------------------------------------------------------------ #
60
+ # Internal helpers #
61
+ # ------------------------------------------------------------------ #
62
+
63
+ def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
64
+ url = f"{self._base_url}/api/v1{path}"
65
+ if params:
66
+ filtered = {k: v for k, v in params.items() if v is not None}
67
+ url = f"{url}?{urlencode(filtered)}"
68
+ req = Request(url, headers={"x-api-key": self._api_key})
69
+ try:
70
+ with urlopen(req, timeout=self._timeout) as resp:
71
+ body = _json.loads(resp.read())
72
+ except Exception as exc:
73
+ raise APIError(0, str(exc)) from exc
74
+ return self._unwrap(body)
75
+
76
+ def _post(self, path: str, payload: Any) -> Any:
77
+ url = f"{self._base_url}/api/v1{path}"
78
+ data = _json.dumps(payload).encode()
79
+ req = Request(
80
+ url,
81
+ data=data,
82
+ headers={"x-api-key": self._api_key, "Content-Type": "application/json"},
83
+ )
84
+ try:
85
+ with urlopen(req, timeout=self._timeout) as resp:
86
+ body = _json.loads(resp.read())
87
+ except Exception as exc:
88
+ raise APIError(0, str(exc)) from exc
89
+ return self._unwrap(body)
90
+
91
+ @staticmethod
92
+ def _unwrap(body: dict[str, Any]) -> Any:
93
+ code = body.get("code", 0)
94
+ if code == 200 or code == 0:
95
+ return body.get("data")
96
+ if code == 401:
97
+ raise AuthError(body.get("msg", "Unauthorized"))
98
+ if code == 429:
99
+ raise RateLimitError(body.get("msg", "Rate limit exceeded"))
100
+ if code == 404:
101
+ raise NotFoundError(body.get("msg", "Not found"))
102
+ raise APIError(code, body.get("msg", "Unknown error"))
103
+
104
+ @staticmethod
105
+ def _market_code(symbol: str) -> tuple[str, str]:
106
+ return split_symbol(symbol)
107
+
108
+ def _sym(self, symbol: str) -> tuple[str, str]:
109
+ return split_symbol(symbol)
110
+
111
+ # ------------------------------------------------------------------ #
112
+ # Quotes #
113
+ # ------------------------------------------------------------------ #
114
+
115
+ def get_quote(self, symbol: str) -> Quote:
116
+ """Get real-time quote for a single symbol.
117
+
118
+ Args:
119
+ symbol: e.g. ``"600519.SH"``, ``"TSLA.US"``, ``"00700.HK"``
120
+
121
+ Returns:
122
+ :class:`Quote` object.
123
+ """
124
+ return self.get_quotes([symbol])[0]
125
+
126
+ def get_quotes(self, symbols: list[str]) -> list[Quote]:
127
+ """Get real-time quotes for multiple symbols (batch).
128
+
129
+ Args:
130
+ symbols: List of symbols, e.g. ``["600519.SH", "000001.SZ", "TSLA.US"]``
131
+
132
+ Returns:
133
+ List of :class:`Quote` objects in the same order as input.
134
+ """
135
+ payload: list[dict[str, Any]] = []
136
+ for sym in symbols:
137
+ market, code = self._market_code(sym)
138
+ payload.append({"market": market, "code": code})
139
+ data = self._post("/quotes", {"stocks": payload})
140
+ rows: list[Any] = data if isinstance(data, list) else (data or [])
141
+ return [parse_quote(r, sym) for r, sym in zip(rows, symbols)]
142
+
143
+ # ------------------------------------------------------------------ #
144
+ # K-lines #
145
+ # ------------------------------------------------------------------ #
146
+
147
+ def get_klines(
148
+ self,
149
+ symbol: str,
150
+ period: str = "daily",
151
+ count: int = 200,
152
+ start: int | None = None,
153
+ ) -> list[KLine]:
154
+ """Get historical K-lines.
155
+
156
+ Args:
157
+ symbol: e.g. ``"600519.SH"``
158
+ period: ``"1m"`` / ``"5m"`` / ``"15m"`` / ``"30m"`` / ``"60m"`` /
159
+ ``"daily"`` / ``"weekly"`` / ``"monthly"``
160
+ count: Number of bars to return (max 800).
161
+ start: Start index (0 = latest).
162
+
163
+ Returns:
164
+ List of :class:`KLine` objects, oldest first.
165
+ """
166
+ _period_map = {
167
+ "1m": "MIN_1", "5m": "MIN_5", "15m": "MIN_15",
168
+ "30m": "MIN_30", "60m": "MIN_60",
169
+ "daily": "DAY", "day": "DAY",
170
+ "weekly": "WEEK", "week": "WEEK",
171
+ "monthly": "MONTH", "month": "MONTH",
172
+ }
173
+ category = _period_map.get(period.lower(), period.upper())
174
+ market, code = self._market_code(symbol)
175
+ data = self._get("/bars", {"market": market, "code": code, "category": category, "count": count, "start": start})
176
+ rows: list[Any] = data if isinstance(data, list) else (data or [])
177
+ return [parse_kline(r) for r in rows]
178
+
179
+ # ------------------------------------------------------------------ #
180
+ # Intraday #
181
+ # ------------------------------------------------------------------ #
182
+
183
+ def get_minute(self, symbol: str) -> list[MinuteBar]:
184
+ """Get today's intraday minute-by-minute data.
185
+
186
+ Args:
187
+ symbol: A-share symbol, e.g. ``"600519.SH"``
188
+
189
+ Returns:
190
+ List of :class:`MinuteBar` objects.
191
+ """
192
+ market, code = self._market_code(symbol)
193
+ data = self._get("/minute", {"market": market, "code": code})
194
+ rows: list[Any] = data if isinstance(data, list) else (data or [])
195
+ return [parse_minute(r) for r in rows]
196
+
197
+ # ------------------------------------------------------------------ #
198
+ # Fund flow #
199
+ # ------------------------------------------------------------------ #
200
+
201
+ def get_fund_flow(self, symbol: str) -> FundFlow:
202
+ """Get main-force fund flow for an A-share stock.
203
+
204
+ Args:
205
+ symbol: e.g. ``"600519.SH"``
206
+
207
+ Returns:
208
+ :class:`FundFlow` object.
209
+ """
210
+ market, code = self._market_code(symbol)
211
+ data = self._get("/fund-flow", {"market": market, "code": code})
212
+ raw: dict[str, Any] = data[0] if isinstance(data, list) and data else (data or {})
213
+ from ._parse import _float
214
+ return FundFlow(
215
+ symbol=symbol,
216
+ inflow_main=_float(raw.get("inflow_main") or raw.get("main_inflow") or 0),
217
+ inflow_retail=_float(raw.get("inflow_retail") or raw.get("retail_inflow") or 0),
218
+ outflow_main=_float(raw.get("outflow_main") or raw.get("main_outflow") or 0),
219
+ outflow_retail=_float(raw.get("outflow_retail") or raw.get("retail_outflow") or 0),
220
+ net_main=_float(raw.get("net_main") or raw.get("main_net") or 0),
221
+ net_retail=_float(raw.get("net_retail") or raw.get("retail_net") or 0),
222
+ _raw=raw,
223
+ )
224
+
225
+ # ------------------------------------------------------------------ #
226
+ # Fundamental — global (all markets) #
227
+ # ------------------------------------------------------------------ #
228
+
229
+ def get_company(self, symbol: str) -> Company:
230
+ """Get company overview.
231
+
232
+ Supports all markets: A-share, HK, US, etc.
233
+
234
+ Args:
235
+ symbol: e.g. ``"600519.SH"``, ``"TSLA.US"``, ``"00700.HK"``
236
+
237
+ Returns:
238
+ :class:`Company` object.
239
+ """
240
+ data = self._get("/fundamental/company", {"symbol": symbol})
241
+ raw: dict[str, Any] = data if isinstance(data, dict) else {}
242
+ return parse_company(raw, symbol)
243
+
244
+ def get_executives(self, symbol: str) -> list[Executive]:
245
+ """Get executive/management information.
246
+
247
+ Args:
248
+ symbol: e.g. ``"600519.SH"``, ``"TSLA.US"``
249
+
250
+ Returns:
251
+ List of :class:`Executive` objects.
252
+ """
253
+ data = self._get("/fundamental/executive", {"symbol": symbol})
254
+ rows: list[Any] = data if isinstance(data, list) else (data or [])
255
+ return [parse_executive(r) for r in rows]
256
+
257
+ def get_financial_reports(
258
+ self,
259
+ symbol: str,
260
+ kind: str = "ALL",
261
+ ) -> list[FinancialReport]:
262
+ """Get financial reports (income statement, balance sheet, cash flow).
263
+
264
+ Args:
265
+ symbol: e.g. ``"600519.SH"``, ``"TSLA.US"``
266
+ kind: ``"ALL"`` / ``"ANNUAL"`` / ``"QUARTERLY"``
267
+
268
+ Returns:
269
+ List of :class:`FinancialReport` objects.
270
+ """
271
+ data = self._get("/fundamental/financial-reports", {"symbol": symbol, "kind": kind})
272
+ rows: list[Any] = data if isinstance(data, list) else (data or [])
273
+ return [parse_financial_report(r) for r in rows]
274
+
275
+ # ------------------------------------------------------------------ #
276
+ # XDXR / Finance (A-share) #
277
+ # ------------------------------------------------------------------ #
278
+
279
+ def get_xdxr(self, symbol: str) -> list[dict]:
280
+ """除权除息记录。symbol: '600519.SH'"""
281
+ market, code = self._sym(symbol)
282
+ return self._get("/xdxr", {"market": market, "code": code}) or []
283
+
284
+ def get_finance(self, symbol: str) -> dict:
285
+ """A股财务指标(PE/PB/EPS 等)。"""
286
+ market, code = self._sym(symbol)
287
+ return self._get("/finance", {"market": market, "code": code}) or {}
288
+
289
+ # ------------------------------------------------------------------ #
290
+ # Transactions #
291
+ # ------------------------------------------------------------------ #
292
+
293
+ def get_transactions(self, symbol: str, start: int = 0, count: int = 200) -> list[dict]:
294
+ """逐笔成交明细。"""
295
+ market, code = self._sym(symbol)
296
+ return self._get("/transaction", {"market": market, "code": code, "start": start, "count": count}) or []
297
+
298
+ def get_transaction_history(self, symbol: str, date: str, start: int = 0, count: int = 200) -> list[dict]:
299
+ """历史逐笔成交。date: '20260617'"""
300
+ market, code = self._sym(symbol)
301
+ return self._get("/transaction/history", {"market": market, "code": code, "date": date, "start": start, "count": count}) or []
302
+
303
+ # ------------------------------------------------------------------ #
304
+ # Fund flow history #
305
+ # ------------------------------------------------------------------ #
306
+
307
+ def get_fund_flow_history(self, symbol: str, start: int = 0, count: int = 10) -> list[dict]:
308
+ """历史资金流向。"""
309
+ market, code = self._sym(symbol)
310
+ return self._get("/fund-flow/history", {"market": market, "code": code, "start": start, "count": count}) or []
311
+
312
+ # ------------------------------------------------------------------ #
313
+ # Minute history #
314
+ # ------------------------------------------------------------------ #
315
+
316
+ def get_minute_history(self, symbol: str, date: str) -> list[MinuteBar]:
317
+ """历史某日分时数据。date: '20260617'"""
318
+ market, code = self._sym(symbol)
319
+ data = self._get("/minute/history", {"market": market, "code": code, "date": date})
320
+ rows: list[Any] = data if isinstance(data, list) else (data or [])
321
+ return [parse_minute(r) for r in rows]
322
+
323
+ # ------------------------------------------------------------------ #
324
+ # Index K-lines #
325
+ # ------------------------------------------------------------------ #
326
+
327
+ def get_index_klines(self, symbol: str, period: str = "daily", count: int = 200, start: int | None = None) -> list[KLine]:
328
+ """指数 K 线(上证指数等)。"""
329
+ _period_map = {
330
+ "1m": "MIN_1", "5m": "MIN_5", "15m": "MIN_15",
331
+ "30m": "MIN_30", "60m": "MIN_60",
332
+ "daily": "DAY", "day": "DAY",
333
+ "weekly": "WEEK", "week": "WEEK",
334
+ "monthly": "MONTH", "month": "MONTH",
335
+ }
336
+ category = _period_map.get(period.lower(), period.upper())
337
+ market, code = self._sym(symbol)
338
+ data = self._get("/bars/index", {"market": market, "code": code, "category": category, "count": count, "start": start})
339
+ rows: list[Any] = data if isinstance(data, list) else (data or [])
340
+ return [parse_kline(r) for r in rows]
341
+
342
+ # ------------------------------------------------------------------ #
343
+ # Market #
344
+ # ------------------------------------------------------------------ #
345
+
346
+ def get_market_stat(self) -> dict:
347
+ """市场统计(涨跌家数、成交额等)。"""
348
+ return self._get("/market/stat") or {}
349
+
350
+ def get_security_list(self, market: str, start: int = 0) -> list[dict]:
351
+ """获取市场证券列表。market: 'SH'/'SZ'/'BJ'"""
352
+ return self._get("/security/list", {"market": market, "start": start}) or []
353
+
354
+ def get_security_count(self, market: str) -> int:
355
+ """获取市场证券总数。"""
356
+ data = self._get("/security/count", {"market": market})
357
+ if isinstance(data, dict):
358
+ return int(data.get("count", 0))
359
+ return int(data or 0)
360
+
361
+ # ------------------------------------------------------------------ #
362
+ # Announcements #
363
+ # ------------------------------------------------------------------ #
364
+
365
+ def get_announcements(self, code: str, count: int = 20, page: int = 1) -> list[dict]:
366
+ """公司公告列表。code: '600519'(不含市场前缀)"""
367
+ return self._get("/announcements", {"code": code, "count": count, "page": page}) or []
368
+
369
+ # ------------------------------------------------------------------ #
370
+ # Sectors / Boards #
371
+ # ------------------------------------------------------------------ #
372
+
373
+ def get_sectors(self, block_file: str = "block_gn.dat") -> list[dict]:
374
+ """板块数据。block_file: 'block_zs.dat'(行业指数) / 'block_gn.dat'(概念) / 'block_fg.dat'(风格)"""
375
+ return self._get("/block", {"filename": block_file}) or []
376
+
377
+ def get_board_ranking(self, board_type: str = "industry", top_n: int = 20, sort_by: str = "change_pct", ascending: bool = False) -> list[dict]:
378
+ """板块涨跌排行。board_type: 'industry'/'concept'/'style'"""
379
+ return self._get("/board-mac/ranking", {"board_type": board_type, "top_n": top_n, "sort_by": sort_by, "ascending": ascending}) or []
380
+
381
+ def get_board_members(self, board_symbol: str, count: int = 50) -> list[dict]:
382
+ """板块成员列表。board_symbol: 板块代码"""
383
+ return self._get("/board-mac/members", {"board_symbol": board_symbol, "count": count}) or []
384
+
385
+ # ------------------------------------------------------------------ #
386
+ # Mac protocol — snapshots / depths #
387
+ # ------------------------------------------------------------------ #
388
+
389
+ def get_snapshots(self, symbols: list[dict]) -> list[dict]:
390
+ """批量行情快照(mac 协议)。symbols: [{'market':'SH','code':'600519'}, ...]"""
391
+ return self._post("/mac/snapshots", {"stocks": symbols}) or []
392
+
393
+ def get_depths(self, symbols: list[dict]) -> list[dict]:
394
+ """批量五档盘口(mac 协议)。symbols: [{'market':'SH','code':'600519'}, ...]"""
395
+ return self._post("/mac/depths", {"stocks": symbols}) or []
396
+
397
+ # ------------------------------------------------------------------ #
398
+ # Global (ex-market) quotes / K-lines #
399
+ # ------------------------------------------------------------------ #
400
+
401
+ def get_global_quote(self, market: str, code: str) -> dict:
402
+ """境外行情(美股/港股/期货等)。market: 'NASDAQ'/'NYSE'/'HKEX' 等"""
403
+ return self._get("/ex/mac/quote", {"market": market, "code": code}) or {}
404
+
405
+ def get_global_klines(self, market: str, code: str, period: str = "daily", count: int = 200, adjust: str | None = None) -> list[KLine]:
406
+ """境外行情 K 线。adjust: 'qfq'(前复权)/'hfq'(后复权)/None"""
407
+ _period_map = {
408
+ "1m": "MIN_1", "5m": "MIN_5", "15m": "MIN_15",
409
+ "30m": "MIN_30", "60m": "MIN_60",
410
+ "daily": "DAY", "day": "DAY",
411
+ "weekly": "WEEK", "week": "WEEK",
412
+ "monthly": "MONTH", "month": "MONTH",
413
+ }
414
+ category = _period_map.get(period.lower(), period.upper())
415
+ data = self._get("/ex/mac/bars", {"market": market, "code": code, "category": category, "count": count, "adjust": adjust})
416
+ rows: list[Any] = data if isinstance(data, list) else (data or [])
417
+ return [parse_kline(r) for r in rows]
418
+
419
+ # ------------------------------------------------------------------ #
420
+ # WebSocket streaming #
421
+ # ------------------------------------------------------------------ #
422
+
423
+ def stream(self) -> RealtimeStream:
424
+ """Open a real-time WebSocket stream.
425
+
426
+ Requires: ``pip install "easyquote[stream]"``
427
+
428
+ Supports A-share, HK, and US symbols simultaneously.
429
+
430
+ Example::
431
+
432
+ with client.stream() as ws:
433
+ ws.subscribe(["600519.SH", "00700.HK", "TSLA.US"])
434
+ for event in ws:
435
+ if event["op"] == "quotes":
436
+ for q in event["data"]:
437
+ print(q["symbol"], q["last_price"])
438
+
439
+ Returns:
440
+ :class:`~easyquote._ws.RealtimeStream` context manager.
441
+ """
442
+ ws_url = (
443
+ self._base_url
444
+ .replace("https://", "wss://")
445
+ .replace("http://", "ws://")
446
+ )
447
+ url = f"{ws_url}/api/v1/ws/stream?api_key={self._api_key}"
448
+ return RealtimeStream(url)
449
+
450
+ def stream_async(self) -> Any:
451
+ """Open an async real-time WebSocket stream.
452
+
453
+ Requires: ``pip install "easyquote[stream-async]"``
454
+
455
+ Example::
456
+
457
+ async with client.stream_async() as ws:
458
+ await ws.subscribe(["600519.SH", "TSLA.US"])
459
+ async for event in ws:
460
+ print(event)
461
+ """
462
+ from ._ws import AsyncRealtimeStream
463
+ ws_url = (
464
+ self._base_url
465
+ .replace("https://", "wss://")
466
+ .replace("http://", "ws://")
467
+ )
468
+ url = f"{ws_url}/api/v1/ws/stream?api_key={self._api_key}"
469
+ return AsyncRealtimeStream(url)
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class EasyQuoteError(Exception):
5
+ """Base exception for all EasyQuote SDK errors."""
6
+
7
+
8
+ class AuthError(EasyQuoteError):
9
+ """Invalid or missing API key."""
10
+
11
+
12
+ class RateLimitError(EasyQuoteError):
13
+ """Daily request quota exceeded."""
14
+
15
+
16
+ class NotFoundError(EasyQuoteError):
17
+ """Requested resource not found."""
18
+
19
+
20
+ class APIError(EasyQuoteError):
21
+ """Unexpected API error."""
22
+
23
+ def __init__(self, status: int, message: str) -> None:
24
+ super().__init__(f"HTTP {status}: {message}")
25
+ self.status = status
26
+ self.message = message
easyquote/_models.py ADDED
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class Quote:
9
+ symbol: str
10
+ name: str
11
+ price: float
12
+ open: float
13
+ high: float
14
+ low: float
15
+ prev_close: float
16
+ change: float
17
+ change_pct: float
18
+ volume: int
19
+ amount: float
20
+ turnover_rate: float | None = None
21
+ timestamp: str | None = None
22
+ _raw: dict[str, Any] = field(default_factory=dict, repr=False)
23
+
24
+ def __repr__(self) -> str:
25
+ sign = "+" if self.change >= 0 else ""
26
+ return f"<Quote {self.symbol} {self.name} {self.price} {sign}{self.change_pct:.2f}%>"
27
+
28
+
29
+ @dataclass
30
+ class KLine:
31
+ datetime: str
32
+ open: float
33
+ high: float
34
+ low: float
35
+ close: float
36
+ volume: int
37
+ amount: float
38
+ _raw: dict[str, Any] = field(default_factory=dict, repr=False)
39
+
40
+ def __repr__(self) -> str:
41
+ return f"<KLine {self.datetime} O={self.open} H={self.high} L={self.low} C={self.close}>"
42
+
43
+
44
+ @dataclass
45
+ class MinuteBar:
46
+ time: str
47
+ price: float
48
+ volume: int
49
+ amount: float
50
+ avg_price: float | None = None
51
+ _raw: dict[str, Any] = field(default_factory=dict, repr=False)
52
+
53
+
54
+ @dataclass
55
+ class FundFlow:
56
+ symbol: str
57
+ inflow_main: float
58
+ inflow_retail: float
59
+ outflow_main: float
60
+ outflow_retail: float
61
+ net_main: float
62
+ net_retail: float
63
+ _raw: dict[str, Any] = field(default_factory=dict, repr=False)
64
+
65
+
66
+ @dataclass
67
+ class Company:
68
+ symbol: str
69
+ name: str
70
+ exchange: str | None = None
71
+ industry: str | None = None
72
+ description: str | None = None
73
+ employees: int | None = None
74
+ website: str | None = None
75
+ founded: str | None = None
76
+ _raw: dict[str, Any] = field(default_factory=dict, repr=False)
77
+
78
+ def __repr__(self) -> str:
79
+ return f"<Company {self.symbol} {self.name}>"
80
+
81
+
82
+ @dataclass
83
+ class Executive:
84
+ name: str
85
+ title: str
86
+ gender: str | None = None
87
+ age: int | None = None
88
+ _raw: dict[str, Any] = field(default_factory=dict, repr=False)
89
+
90
+ def __repr__(self) -> str:
91
+ return f"<Executive {self.name} ({self.title})>"
92
+
93
+
94
+ @dataclass
95
+ class FinancialReport:
96
+ period: str
97
+ report_type: str
98
+ revenue: float | None = None
99
+ net_income: float | None = None
100
+ eps: float | None = None
101
+ _raw: dict[str, Any] = field(default_factory=dict, repr=False)
easyquote/_parse.py ADDED
@@ -0,0 +1,142 @@
1
+ """Symbol parsing and response mapping helpers."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any
5
+
6
+ from ._models import (
7
+ Company,
8
+ Executive,
9
+ FinancialReport,
10
+ FundFlow,
11
+ KLine,
12
+ MinuteBar,
13
+ Quote,
14
+ )
15
+
16
+ _SUFFIX_TO_MARKET = {
17
+ "SH": "SH",
18
+ "SZ": "SZ",
19
+ "BJ": "BJ",
20
+ }
21
+
22
+
23
+ def split_symbol(symbol: str) -> tuple[str, str]:
24
+ """Split '600519.SH' → ('SH', '600519'). Pass-through for global symbols."""
25
+ if "." in symbol:
26
+ code, suffix = symbol.rsplit(".", 1)
27
+ suffix = suffix.upper()
28
+ if suffix in _SUFFIX_TO_MARKET:
29
+ return _SUFFIX_TO_MARKET[suffix], code
30
+ raise ValueError(
31
+ f"Cannot split symbol '{symbol}' into (market, code). "
32
+ "Use market= and code= parameters explicitly for A-share endpoints, "
33
+ "or use dot notation like '600519.SH'."
34
+ )
35
+
36
+
37
+ def _float(v: Any) -> float:
38
+ try:
39
+ return float(v)
40
+ except (TypeError, ValueError):
41
+ return 0.0
42
+
43
+
44
+ def _int(v: Any) -> int:
45
+ try:
46
+ return int(v)
47
+ except (TypeError, ValueError):
48
+ return 0
49
+
50
+
51
+ def parse_quote(raw: dict[str, Any], symbol: str = "") -> Quote:
52
+ # Try common field names from the API
53
+ price = _float(raw.get("close") or raw.get("price") or raw.get("last_price") or 0)
54
+ prev = _float(raw.get("last_close") or raw.get("prev_close") or 0)
55
+ change = _float(raw.get("change") or (price - prev if prev else 0))
56
+ change_pct = _float(raw.get("change_pct") or raw.get("pct") or (change / prev * 100 if prev else 0))
57
+
58
+ market_map = {0: "SZ", 1: "SH"}
59
+ mkt = raw.get("market")
60
+ code = raw.get("code", "")
61
+ if isinstance(mkt, int):
62
+ sym = f"{code}.{market_map.get(mkt, str(mkt))}"
63
+ elif mkt:
64
+ sym = f"{code}.{mkt}"
65
+ else:
66
+ sym = symbol or code
67
+
68
+ return Quote(
69
+ symbol=sym,
70
+ name=raw.get("name") or raw.get("stock_name") or "",
71
+ price=price,
72
+ open=_float(raw.get("open") or 0),
73
+ high=_float(raw.get("high") or 0),
74
+ low=_float(raw.get("low") or 0),
75
+ prev_close=prev,
76
+ change=change,
77
+ change_pct=change_pct,
78
+ volume=_int(raw.get("vol") or raw.get("volume") or 0),
79
+ amount=_float(raw.get("amount") or 0),
80
+ turnover_rate=_float(raw.get("turnover_rate") or 0) or None,
81
+ timestamp=raw.get("timestamp") or raw.get("time"),
82
+ _raw=raw,
83
+ )
84
+
85
+
86
+ def parse_kline(raw: dict[str, Any]) -> KLine:
87
+ return KLine(
88
+ datetime=str(raw.get("datetime") or raw.get("date") or raw.get("time") or ""),
89
+ open=_float(raw.get("open") or 0),
90
+ high=_float(raw.get("high") or 0),
91
+ low=_float(raw.get("low") or 0),
92
+ close=_float(raw.get("close") or 0),
93
+ volume=_int(raw.get("vol") or raw.get("volume") or 0),
94
+ amount=_float(raw.get("amount") or 0),
95
+ _raw=raw,
96
+ )
97
+
98
+
99
+ def parse_minute(raw: dict[str, Any]) -> MinuteBar:
100
+ return MinuteBar(
101
+ time=str(raw.get("time") or raw.get("datetime") or ""),
102
+ price=_float(raw.get("price") or raw.get("close") or 0),
103
+ volume=_int(raw.get("vol") or raw.get("volume") or 0),
104
+ amount=_float(raw.get("amount") or 0),
105
+ avg_price=_float(raw.get("avg_price") or 0) or None,
106
+ _raw=raw,
107
+ )
108
+
109
+
110
+ def parse_company(raw: dict[str, Any], symbol: str = "") -> Company:
111
+ return Company(
112
+ symbol=raw.get("symbol") or symbol,
113
+ name=raw.get("name") or raw.get("company_name") or "",
114
+ exchange=raw.get("exchange"),
115
+ industry=raw.get("industry") or raw.get("industry_name"),
116
+ description=raw.get("description") or raw.get("profile"),
117
+ employees=_int(raw.get("employees") or 0) or None,
118
+ website=raw.get("website"),
119
+ founded=raw.get("founded") or raw.get("listing_date"),
120
+ _raw=raw,
121
+ )
122
+
123
+
124
+ def parse_executive(raw: dict[str, Any]) -> Executive:
125
+ return Executive(
126
+ name=raw.get("name") or "",
127
+ title=raw.get("title") or raw.get("position") or "",
128
+ gender=raw.get("gender"),
129
+ age=_int(raw.get("age") or 0) or None,
130
+ _raw=raw,
131
+ )
132
+
133
+
134
+ def parse_financial_report(raw: dict[str, Any]) -> FinancialReport:
135
+ return FinancialReport(
136
+ period=raw.get("period") or raw.get("report_date") or "",
137
+ report_type=raw.get("report_type") or raw.get("kind") or "",
138
+ revenue=_float(raw.get("revenue") or 0) or None,
139
+ net_income=_float(raw.get("net_income") or raw.get("net_profit") or 0) or None,
140
+ eps=_float(raw.get("eps") or 0) or None,
141
+ _raw=raw,
142
+ )
easyquote/_ws.py ADDED
@@ -0,0 +1,155 @@
1
+ """WebSocket streaming client for real-time market data."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import threading
6
+ from typing import Any, Callable, Iterator
7
+
8
+
9
+ class RealtimeStream:
10
+ """Synchronous WebSocket stream. Use as a context manager.
11
+
12
+ Example::
13
+
14
+ with client.stream() as ws:
15
+ ws.subscribe(["600519.SH", "TSLA.US"])
16
+ for event in ws:
17
+ if event["op"] == "quotes":
18
+ for q in event["data"]:
19
+ print(q["symbol"], q["last_price"])
20
+ """
21
+
22
+ def __init__(self, url: str) -> None:
23
+ self._url = url
24
+ self._ws: Any = None
25
+ self._queue: list[dict[str, Any]] = []
26
+ self._lock = threading.Lock()
27
+ self._event = threading.Event()
28
+ self._closed = False
29
+ self._error: Exception | None = None
30
+
31
+ def _connect(self) -> None:
32
+ try:
33
+ import websocket # type: ignore[import]
34
+ except ImportError as exc:
35
+ raise ImportError(
36
+ "websocket-client is required for streaming: pip install websocket-client"
37
+ ) from exc
38
+
39
+ def _on_message(ws: Any, msg: str) -> None:
40
+ try:
41
+ data = json.loads(msg)
42
+ except json.JSONDecodeError:
43
+ return
44
+ with self._lock:
45
+ self._queue.append(data)
46
+ self._event.set()
47
+
48
+ def _on_error(ws: Any, err: Exception) -> None:
49
+ self._error = err
50
+ self._event.set()
51
+
52
+ def _on_close(ws: Any, code: int, reason: str) -> None:
53
+ self._closed = True
54
+ self._event.set()
55
+
56
+ self._ws = websocket.WebSocketApp(
57
+ self._url,
58
+ on_message=_on_message,
59
+ on_error=_on_error,
60
+ on_close=_on_close,
61
+ )
62
+ t = threading.Thread(target=self._ws.run_forever, daemon=True)
63
+ t.start()
64
+
65
+ def subscribe(self, symbols: list[str]) -> None:
66
+ """Subscribe to real-time quotes and depth for the given symbols."""
67
+ self._send({"op": "subscribe", "symbols": symbols})
68
+
69
+ def unsubscribe(self, symbols: list[str]) -> None:
70
+ self._send({"op": "unsubscribe", "symbols": symbols})
71
+
72
+ def status(self) -> None:
73
+ """Request current subscription status."""
74
+ self._send({"op": "status"})
75
+
76
+ def _send(self, msg: dict[str, Any]) -> None:
77
+ if self._ws is None:
78
+ raise RuntimeError("Stream not connected. Use as a context manager.")
79
+ self._ws.send(json.dumps(msg))
80
+
81
+ def __enter__(self) -> "RealtimeStream":
82
+ self._connect()
83
+ return self
84
+
85
+ def __exit__(self, *_: Any) -> None:
86
+ self.close()
87
+
88
+ def close(self) -> None:
89
+ self._closed = True
90
+ if self._ws:
91
+ self._ws.close()
92
+
93
+ def __iter__(self) -> Iterator[dict[str, Any]]:
94
+ while not self._closed:
95
+ self._event.wait(timeout=1.0)
96
+ self._event.clear()
97
+ if self._error:
98
+ raise self._error
99
+ with self._lock:
100
+ items, self._queue = self._queue[:], []
101
+ yield from items
102
+
103
+
104
+ class AsyncRealtimeStream:
105
+ """Async WebSocket stream.
106
+
107
+ Example::
108
+
109
+ async with client.stream_async() as ws:
110
+ await ws.subscribe(["600519.SH", "TSLA.US"])
111
+ async for event in ws:
112
+ print(event)
113
+ """
114
+
115
+ def __init__(self, url: str) -> None:
116
+ self._url = url
117
+ self._ws: Any = None
118
+
119
+ async def _connect(self) -> None:
120
+ try:
121
+ import websockets # type: ignore[import]
122
+ except ImportError as exc:
123
+ raise ImportError(
124
+ "websockets is required for async streaming: pip install websockets"
125
+ ) from exc
126
+ self._ws = await websockets.connect(self._url)
127
+
128
+ async def subscribe(self, symbols: list[str]) -> None:
129
+ await self._send({"op": "subscribe", "symbols": symbols})
130
+
131
+ async def unsubscribe(self, symbols: list[str]) -> None:
132
+ await self._send({"op": "unsubscribe", "symbols": symbols})
133
+
134
+ async def status(self) -> None:
135
+ await self._send({"op": "status"})
136
+
137
+ async def _send(self, msg: dict[str, Any]) -> None:
138
+ await self._ws.send(json.dumps(msg))
139
+
140
+ async def __aenter__(self) -> "AsyncRealtimeStream":
141
+ await self._connect()
142
+ return self
143
+
144
+ async def __aexit__(self, *_: Any) -> None:
145
+ await self._ws.close()
146
+
147
+ def __aiter__(self) -> "AsyncRealtimeStream":
148
+ return self
149
+
150
+ async def __anext__(self) -> dict[str, Any]:
151
+ try:
152
+ msg = await self._ws.recv()
153
+ return json.loads(msg) # type: ignore[no-any-return]
154
+ except Exception as exc:
155
+ raise StopAsyncIteration from exc
@@ -0,0 +1,284 @@
1
+ Metadata-Version: 2.4
2
+ Name: easyquotes
3
+ Version: 0.1.0
4
+ Summary: EasyQuote Python SDK — global market data (A-share, HK, US) in a few lines of code
5
+ Project-URL: Homepage, https://easyquote.io
6
+ Project-URL: Documentation, https://easyquote.io/docs
7
+ Project-URL: Repository, https://github.com/easyquotes/easyquotes
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Office/Business :: Financial
18
+ Requires-Python: >=3.10
19
+ Provides-Extra: stream
20
+ Requires-Dist: websocket-client>=1.6; extra == 'stream'
21
+ Provides-Extra: stream-async
22
+ Requires-Dist: websockets>=12.0; extra == 'stream-async'
23
+ Description-Content-Type: text/markdown
24
+
25
+ # EasyQuote Python SDK
26
+
27
+ A股、港股、美股及境外市场行情数据 Python 客户端。基于 EasyQuote HTTP API,支持实时行情、历史 K 线、逐笔成交、资金流向、基本面数据、板块数据及 WebSocket 推送。
28
+
29
+ ## 安装
30
+
31
+ ```bash
32
+ pip install easyquotes
33
+ ```
34
+
35
+ 可选扩展:
36
+
37
+ ```bash
38
+ pip install "easyquotes[stream]" # WebSocket 同步推送
39
+ pip install "easyquotes[stream-async]" # WebSocket 异步推送
40
+ ```
41
+
42
+ Python 版本要求:>= 3.10
43
+
44
+ ## 快速开始
45
+
46
+ 通过环境变量配置 API Key:
47
+
48
+ ```bash
49
+ export EASYQUOTE_API_KEY="eq_your_key_here"
50
+ ```
51
+
52
+ ```python
53
+ from easyquote import EasyQuoteClient
54
+
55
+ client = EasyQuoteClient() # 自动读取 EASYQUOTE_API_KEY
56
+ quote = client.get_quote("600519.SH")
57
+ print(f"{quote.name}: {quote.price} ({quote.change_pct:+.2f}%)")
58
+ ```
59
+
60
+ 也可以直接传入 api_key:
61
+
62
+ ```python
63
+ client = EasyQuoteClient(api_key="eq_your_key_here")
64
+ ```
65
+
66
+ ## 标的代码格式
67
+
68
+ | 市场 | 格式示例 | 说明 |
69
+ |----------|------------------|--------------------------|
70
+ | A 股上交 | `600519.SH` | 沪市,后缀 `.SH` |
71
+ | A 股深交 | `000001.SZ` | 深市,后缀 `.SZ` |
72
+ | A 股北交 | `836239.BJ` | 北交所,后缀 `.BJ` |
73
+ | 港股 | `00700.HK` | 五位数代码,后缀 `.HK` |
74
+ | 美股 | `TSLA.US` | 股票代码,后缀 `.US` |
75
+ | 指数 | `000001.SH` | 上证指数等 |
76
+
77
+ ## API 参考
78
+
79
+ ### 行情 (Quotes)
80
+
81
+ | 方法 | 说明 |
82
+ |------|------|
83
+ | `get_quote(symbol)` | 单只标的实时行情,返回 `Quote` |
84
+ | `get_quotes(symbols)` | 批量实时行情,返回 `list[Quote]` |
85
+ | `get_snapshots(symbols)` | 批量行情快照(mac 协议),`symbols` 为 `[{'market':'SH','code':'600519'}]` |
86
+ | `get_depths(symbols)` | 批量五档盘口(mac 协议) |
87
+
88
+ ### K 线 (K-Lines)
89
+
90
+ | 方法 | 说明 |
91
+ |------|------|
92
+ | `get_klines(symbol, period, count, start)` | A 股/港股/美股历史 K 线,返回 `list[KLine]` |
93
+ | `get_index_klines(symbol, period, count, start)` | 指数 K 线 |
94
+ | `get_global_klines(market, code, period, count, adjust)` | 境外行情 K 线,`adjust='qfq'`(前复权)/`'hfq'`(后复权) |
95
+
96
+ `period` 可选值:`"1m"` / `"5m"` / `"15m"` / `"30m"` / `"60m"` / `"daily"` / `"weekly"` / `"monthly"`
97
+
98
+ ### 分时 (Intraday)
99
+
100
+ | 方法 | 说明 |
101
+ |------|------|
102
+ | `get_minute(symbol)` | 当日分时数据,返回 `list[MinuteBar]` |
103
+ | `get_minute_history(symbol, date)` | 历史某日分时数据,`date='20260617'` |
104
+
105
+ ### 逐笔 (Transactions)
106
+
107
+ | 方法 | 说明 |
108
+ |------|------|
109
+ | `get_transactions(symbol, start, count)` | 今日逐笔成交明细 |
110
+ | `get_transaction_history(symbol, date, start, count)` | 历史逐笔成交,`date='20260617'` |
111
+
112
+ ### 资金流向 (Fund Flow)
113
+
114
+ | 方法 | 说明 |
115
+ |------|------|
116
+ | `get_fund_flow(symbol)` | 当日主力资金流向,返回 `FundFlow` |
117
+ | `get_fund_flow_history(symbol, start, count)` | 历史资金流向列表 |
118
+
119
+ ### 基本面 A 股 (A-share Fundamentals)
120
+
121
+ | 方法 | 说明 |
122
+ |------|------|
123
+ | `get_finance(symbol)` | A 股财务指标(PE/PB/EPS 等) |
124
+ | `get_xdxr(symbol)` | 除权除息记录 |
125
+ | `get_announcements(code, count, page)` | 公司公告列表,`code='600519'`(不含市场后缀) |
126
+
127
+ ### 基本面 全球 (Global Fundamentals)
128
+
129
+ | 方法 | 说明 |
130
+ |------|------|
131
+ | `get_company(symbol)` | 公司概况,返回 `Company`,支持 A 股/港股/美股 |
132
+ | `get_executives(symbol)` | 高管信息,返回 `list[Executive]` |
133
+ | `get_financial_reports(symbol, kind)` | 财务报告,`kind='ALL'/'ANNUAL'/'QUARTERLY'` |
134
+
135
+ ### 板块 (Sectors)
136
+
137
+ | 方法 | 说明 |
138
+ |------|------|
139
+ | `get_sectors(block_file)` | 板块数据,`block_file` 可选 `'block_zs.dat'`(行业指数)/ `'block_gn.dat'`(概念)/ `'block_fg.dat'`(风格) |
140
+ | `get_board_ranking(board_type, top_n, sort_by, ascending)` | 板块涨跌排行,`board_type='industry'/'concept'/'style'` |
141
+ | `get_board_members(board_symbol, count)` | 板块成员列表 |
142
+
143
+ ### 市场 (Market)
144
+
145
+ | 方法 | 说明 |
146
+ |------|------|
147
+ | `get_market_stat()` | 市场统计(涨跌家数、成交额等) |
148
+ | `get_security_list(market, start)` | 证券列表,`market='SH'/'SZ'/'BJ'` |
149
+ | `get_security_count(market)` | 证券总数,返回 `int` |
150
+
151
+ ### 境外行情 (Global Quotes)
152
+
153
+ | 方法 | 说明 |
154
+ |------|------|
155
+ | `get_global_quote(market, code)` | 境外单只行情,`market='NASDAQ'/'NYSE'/'HKEX'` 等 |
156
+ | `get_global_klines(market, code, period, count, adjust)` | 境外 K 线 |
157
+
158
+ ### 实时推送 (Streaming)
159
+
160
+ | 方法 | 说明 |
161
+ |------|------|
162
+ | `stream()` | 同步 WebSocket 推送,需安装 `easyquotes[stream]` |
163
+ | `stream_async()` | 异步 WebSocket 推送,需安装 `easyquotes[stream-async]` |
164
+
165
+ ## 使用示例
166
+
167
+ ### 批量获取行情
168
+
169
+ ```python
170
+ quotes = client.get_quotes(["600519.SH", "000001.SZ", "TSLA.US", "00700.HK"])
171
+ for q in quotes:
172
+ print(f"{q.symbol} {q.price:>10.2f} {q.change_pct:+.2f}%")
173
+ ```
174
+
175
+ ### K 线数据
176
+
177
+ ```python
178
+ klines = client.get_klines("600519.SH", period="daily", count=120)
179
+ for k in klines[-5:]:
180
+ print(f"{k.datetime} O:{k.open} H:{k.high} L:{k.low} C:{k.close} V:{k.volume}")
181
+ ```
182
+
183
+ ### 历史分时
184
+
185
+ ```python
186
+ bars = client.get_minute_history("600519.SH", date="20260617")
187
+ for b in bars[:10]:
188
+ print(b.time, b.price, b.volume)
189
+ ```
190
+
191
+ ### 资金流向
192
+
193
+ ```python
194
+ ff = client.get_fund_flow("600519.SH")
195
+ print(f"主力净流入: {ff.net_main / 1e8:.2f} 亿")
196
+ ```
197
+
198
+ ### 板块排行
199
+
200
+ ```python
201
+ ranking = client.get_board_ranking(board_type="industry", top_n=10, sort_by="change_pct")
202
+ for b in ranking:
203
+ print(b["name"], b["change_pct"])
204
+ ```
205
+
206
+ ### 同步 WebSocket 推送
207
+
208
+ ```python
209
+ with client.stream() as ws:
210
+ ws.subscribe(["600519.SH", "000001.SZ", "TSLA.US"])
211
+ for event in ws:
212
+ if event["op"] == "quotes":
213
+ for q in event["data"]:
214
+ print(q["symbol"], q["last_price"])
215
+ ```
216
+
217
+ ### 异步 WebSocket 推送
218
+
219
+ ```python
220
+ import asyncio
221
+
222
+ async def main():
223
+ async with client.stream_async() as ws:
224
+ await ws.subscribe(["600519.SH", "TSLA.US"])
225
+ async for event in ws:
226
+ print(event)
227
+
228
+ asyncio.run(main())
229
+ ```
230
+
231
+ ### 逐笔成交
232
+
233
+ ```python
234
+ ticks = client.get_transactions("600519.SH", start=0, count=50)
235
+ for t in ticks:
236
+ print(t["time"], t["price"], t["volume"], t["direction"])
237
+ ```
238
+
239
+ ### 境外行情
240
+
241
+ ```python
242
+ # 获取纳斯达克个股 K 线
243
+ klines = client.get_global_klines("NASDAQ", "TSLA", period="daily", count=60)
244
+
245
+ # 获取港股行情
246
+ quote = client.get_global_quote("HKEX", "00700")
247
+ ```
248
+
249
+ ## 错误处理
250
+
251
+ ```python
252
+ from easyquote import EasyQuoteClient
253
+ from easyquote import AuthError, RateLimitError, APIError, NotFoundError
254
+
255
+ client = EasyQuoteClient(api_key="eq_your_key")
256
+
257
+ try:
258
+ quote = client.get_quote("600519.SH")
259
+ except AuthError:
260
+ print("API Key 无效或未设置")
261
+ except RateLimitError:
262
+ print("请求频率超限,请稍后重试")
263
+ except NotFoundError:
264
+ print("标的不存在")
265
+ except APIError as e:
266
+ print(f"API 错误 {e.code}: {e.message}")
267
+ ```
268
+
269
+ ## 自托管部署
270
+
271
+ 如果使用自托管的 EasyQuote 后端(基于 easy-tdx 的 FastAPI 服务),可以通过 `base_url` 参数指定服务地址:
272
+
273
+ ```python
274
+ client = EasyQuoteClient(
275
+ api_key="eq_your_key",
276
+ base_url="http://localhost:8000",
277
+ )
278
+ ```
279
+
280
+ HTTP 和 WebSocket 均会自动切换到指定地址(`wss://` 对应 HTTPS,`ws://` 对应 HTTP)。
281
+
282
+ ## 许可证
283
+
284
+ MIT
@@ -0,0 +1,10 @@
1
+ easyquote/__init__.py,sha256=KPjGCAPhjhy5GUgqZwdBn0avoyPp9KTE1aYGa6ymozM,598
2
+ easyquote/_client.py,sha256=7xSTFTeYL6NpfCPVgbaf6uPTeL7Zl2grIm63yiO7bfQ,20170
3
+ easyquote/_exceptions.py,sha256=lIbgYoiBHcpyXnYAyL6BrP85LFxIjNn7iHROGBPoNrQ,597
4
+ easyquote/_models.py,sha256=GpoHqMpJ61dzUvzj4kxRMjlJ6lUGbI7CBQUmOrokHXo,2331
5
+ easyquote/_parse.py,sha256=ZwylAc9ky1XqTKkwWSN3ukrVkxJ1aocIxLHfVVZSyTY,4521
6
+ easyquote/_ws.py,sha256=362cM84LRpCBSUhUoX1_Tix8pLs_coWsOsD2JwVc4ng,4754
7
+ easyquotes-0.1.0.dist-info/METADATA,sha256=W1MLsyqh2FRE8WSvOkZ11ls_O6zFXoNpXsaPEvt6h6c,8447
8
+ easyquotes-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
9
+ easyquotes-0.1.0.dist-info/licenses/LICENSE,sha256=H1fZkYpkk2PpCje142VjP2mHdQAt2oteOk2zlMBGw2M,1079
10
+ easyquotes-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EasyQuote contributors
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.