chartremotely 0.3.6__tar.gz → 0.3.7__tar.gz

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.
Files changed (68) hide show
  1. {chartremotely-0.3.6 → chartremotely-0.3.7}/PKG-INFO +1 -1
  2. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/cli.py +1 -1
  3. chartremotely-0.3.7/chartremotely/registry.py +122 -0
  4. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/resolve.py +7 -1
  5. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/symbol.py +1 -3
  6. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/understand.py +3 -3
  7. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely.egg-info/PKG-INFO +1 -1
  8. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely.egg-info/SOURCES.txt +1 -0
  9. {chartremotely-0.3.6 → chartremotely-0.3.7}/pyproject.toml +1 -1
  10. chartremotely-0.3.7/tests/test_registry.py +119 -0
  11. chartremotely-0.3.6/chartremotely/registry.py +0 -53
  12. {chartremotely-0.3.6 → chartremotely-0.3.7}/LICENSE +0 -0
  13. {chartremotely-0.3.6 → chartremotely-0.3.7}/README.md +0 -0
  14. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/__init__.py +0 -0
  15. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/answer.py +0 -0
  16. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/assets/shortcut.plist +0 -0
  17. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/ax.py +0 -0
  18. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/config.py +0 -0
  19. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/displays.py +0 -0
  20. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/doctor.py +0 -0
  21. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/guilock.py +0 -0
  22. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/hearing.py +0 -0
  23. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/holder.py +0 -0
  24. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/keystore.py +0 -0
  25. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/layout.py +0 -0
  26. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/mcpclient.py +0 -0
  27. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/patron.py +0 -0
  28. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/popups.py +0 -0
  29. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/push.py +0 -0
  30. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/recent.py +0 -0
  31. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/relay.py +0 -0
  32. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/requestlog.py +0 -0
  33. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/scales.py +0 -0
  34. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/screen.py +0 -0
  35. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/server.py +0 -0
  36. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/services.py +0 -0
  37. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/setup.py +0 -0
  38. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/shortcut.py +0 -0
  39. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/snapshot.py +0 -0
  40. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/studies.py +0 -0
  41. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/tailnet.py +0 -0
  42. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/terminal.py +0 -0
  43. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/timeframe.py +0 -0
  44. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/vocab.py +0 -0
  45. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/voice.py +0 -0
  46. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely/window.py +0 -0
  47. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely.egg-info/dependency_links.txt +0 -0
  48. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely.egg-info/entry_points.txt +0 -0
  49. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely.egg-info/requires.txt +0 -0
  50. {chartremotely-0.3.6 → chartremotely-0.3.7}/chartremotely.egg-info/top_level.txt +0 -0
  51. {chartremotely-0.3.6 → chartremotely-0.3.7}/setup.cfg +0 -0
  52. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_changelog.py +0 -0
  53. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_hearing.py +0 -0
  54. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_patron.py +0 -0
  55. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_popups.py +0 -0
  56. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_push.py +0 -0
  57. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_recent.py +0 -0
  58. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_relay.py +0 -0
  59. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_requestlog.py +0 -0
  60. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_resolve.py +0 -0
  61. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_scales.py +0 -0
  62. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_screen.py +0 -0
  63. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_setup.py +0 -0
  64. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_snapshot.py +0 -0
  65. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_stamp_changelog.py +0 -0
  66. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_timeframe.py +0 -0
  67. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_understand.py +0 -0
  68. {chartremotely-0.3.6 → chartremotely-0.3.7}/tests/test_voice.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -128,7 +128,7 @@ def main(argv: list[str] | None = None) -> int:
128
128
 
129
129
 
130
130
  def _looks_like_ticker(text: str) -> bool:
131
- from .symbol import TICKER
131
+ from .resolve import TICKER
132
132
  return bool(TICKER.match(text.strip().upper()))
133
133
 
134
134
 
@@ -0,0 +1,122 @@
1
+ """The security registry: SEC issuers plus listed ETFs, cached locally.
2
+
3
+ The SEC file holds roughly 10,500 US issuers, ordered by prominence, which
4
+ makes the index a usable tie-breaker: NVDA, AAPL and GOOGL are the first
5
+ three entries. It lists a fund only when the fund files as its own company
6
+ (SPY, GLD), so the sector and thematic ETFs that file as a series of a
7
+ parent trust - XBI, XLE, QQQ, SMH - are absent from it.
8
+
9
+ Nasdaq Trader's symbol directory fills that gap: every US-listed security,
10
+ each flagged as an ETF or not, with its full name. Only its ETF rows are
11
+ taken, ranked after every SEC issuer, so a spoken company name still finds
12
+ the company before a fund that shares a word with it.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ import urllib.request
20
+
21
+ from .config import DATA_DIR
22
+
23
+ SEC_URL = "https://www.sec.gov/files/company_tickers.json"
24
+ NASDAQ_URL = "https://www.nasdaqtrader.com/dynamic/SymDir/nasdaqtraded.txt"
25
+ CACHE = DATA_DIR / "tickers.json"
26
+ ETF_CACHE = DATA_DIR / "etfs.json"
27
+ MAX_AGE = 7 * 24 * 3600
28
+ # The SEC requires a User-Agent carrying a contact address and answers 403
29
+ # without one. A repository URL alone is not enough.
30
+ DEFAULT_CONTACT = "chartremotely@example.com"
31
+
32
+
33
+ def _user_agent() -> str:
34
+ from .config import load
35
+ contact = load().get("contact") or DEFAULT_CONTACT
36
+ # SEC's documented format is plain "Name email". Anything fancier is
37
+ # rejected: a User-Agent carrying a URL answers 403, as does one with no
38
+ # contact address at all.
39
+ return f"ChartRemotely {contact}"
40
+
41
+
42
+ def _fresh(path=CACHE) -> bool:
43
+ return path.exists() and time.time() - path.stat().st_mtime < MAX_AGE
44
+
45
+
46
+ def _store(path, rows: list) -> None:
47
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
48
+ tmp = path.with_suffix(".tmp")
49
+ tmp.write_text(json.dumps(rows))
50
+ tmp.replace(path)
51
+
52
+
53
+ def refresh() -> list[dict]:
54
+ request = urllib.request.Request(SEC_URL, headers={"User-Agent": _user_agent()})
55
+ with urllib.request.urlopen(request, timeout=20) as response:
56
+ raw = json.load(response)
57
+ rows = [{"t": v["ticker"], "n": v["title"], "r": rank}
58
+ for rank, v in enumerate(raw.values())]
59
+ _store(CACHE, rows)
60
+ return rows
61
+
62
+
63
+ def parse_etfs(text: str) -> list[list[str]]:
64
+ """``[symbol, name]`` for each live ETF in a Nasdaq Trader directory.
65
+
66
+ Pipe-delimited with a header row and a closing "File Creation Time"
67
+ line. Test issues and symbols no longer traded are dropped.
68
+ """
69
+ lines = text.splitlines()
70
+ if not lines:
71
+ return []
72
+ head = lines[0].split("|")
73
+ try:
74
+ traded, sym, name, etf, test = (head.index(c) for c in (
75
+ "Nasdaq Traded", "Symbol", "Security Name", "ETF", "Test Issue"))
76
+ except ValueError:
77
+ return []
78
+ out = []
79
+ for line in lines[1:]:
80
+ f = line.split("|")
81
+ if len(f) < len(head):
82
+ continue
83
+ if f[traded] == "Y" and f[etf] == "Y" and f[test] == "N" and f[sym]:
84
+ out.append([f[sym].strip(), f[name].strip()])
85
+ return out
86
+
87
+
88
+ def refresh_etfs() -> list[list[str]]:
89
+ request = urllib.request.Request(NASDAQ_URL, headers={"User-Agent": _user_agent()})
90
+ with urllib.request.urlopen(request, timeout=20) as response:
91
+ etfs = parse_etfs(response.read().decode("utf-8", "replace"))
92
+ if etfs:
93
+ _store(ETF_CACHE, etfs)
94
+ return etfs
95
+
96
+
97
+ def _etfs(force: bool) -> list[list[str]]:
98
+ """The ETF rows, refreshed weekly. Never what breaks a lookup: when the
99
+ directory cannot be fetched, the last copy serves, else no ETFs."""
100
+ if force or not _fresh(ETF_CACHE):
101
+ try:
102
+ etfs = refresh_etfs()
103
+ if etfs:
104
+ return etfs
105
+ except (OSError, ValueError):
106
+ pass
107
+ try:
108
+ return json.loads(ETF_CACHE.read_text())
109
+ except (OSError, ValueError):
110
+ return []
111
+
112
+
113
+ def merge(sec: list[dict], etfs: list[list[str]]) -> list[dict]:
114
+ """SEC rows first, then each ETF the SEC file lacks, ranked after them."""
115
+ have = {r["t"] for r in sec}
116
+ extra = [(t, n) for t, n in etfs if t not in have]
117
+ return sec + [{"t": t, "n": n, "r": len(sec) + i} for i, (t, n) in enumerate(extra)]
118
+
119
+
120
+ def load(force: bool = False) -> list[dict]:
121
+ sec = refresh() if force or not _fresh() else json.loads(CACHE.read_text())
122
+ return merge(sec, _etfs(force))
@@ -30,7 +30,13 @@ import difflib
30
30
  import re
31
31
  from dataclasses import dataclass, field
32
32
 
33
- __all__ = ["Decision", "candidates", "decide", "normalize", "phonetic", "resolve", "spelled"]
33
+ __all__ = ["TICKER", "Decision", "candidates", "decide", "normalize", "phonetic", "resolve", "spelled"]
34
+
35
+ # A stock (PLTR), share class (BRK/B), crypto pair (BTC/USD), index ($SPX)
36
+ # or dated future (/ESZ25:XCME). thinkorswim's symbol field must hold one
37
+ # of these before the agent types into it, so a shape left out here refuses
38
+ # every request that follows it.
39
+ TICKER = re.compile(r"^[./$]?[A-Z][A-Z0-9]{0,5}([/.][A-Z]{1,4})?(:[A-Z]+)?$")
34
40
 
35
41
  # Corporate furniture, stripped from both sides before comparison. "and"
36
42
  # is here because "&" normalises to it, so DEERE & CO reduces to "deere".
@@ -14,9 +14,7 @@ import time
14
14
  import Quartz
15
15
 
16
16
  from . import ax, config, layout, popups
17
-
18
- # Accepts tickers, futures (/ES), indices (.SPX) and share classes (BRK/B).
19
- TICKER = re.compile(r"^[./$]?[A-Z]{1,6}([/.][A-Z]{1,2})?(:[A-Z]+)?$")
17
+ from .resolve import TICKER
20
18
 
21
19
  #: Longest show() waits for the chart's title to name the new symbol.
22
20
  COMMIT_WAIT = 3.0
@@ -2,7 +2,7 @@
2
2
 
3
3
  Siri's dictation (and, next, a Whisper transcription of the whole
4
4
  utterance) matches against all of English. What we need back is small: a
5
- company from the SEC list, one of ten time frames, and the name of a
5
+ company or ETF from the registry, one of ten time frames, and the name of a
6
6
  display. This module takes the text as heard - "Palantir half on mac mini",
7
7
  "shop thirty minutes", "GE daily on the studio", "apple as is on desk" - and
8
8
  recovers those three, refusing rather than guessing.
@@ -22,7 +22,7 @@ It works right to left, the way people say it:
22
22
  symbols charted lately as a prior. Too close to call is an answer too:
23
23
  the options, so the reply can ask.
24
24
 
25
- Pure and deterministic: no LLM, no network beyond the cached SEC registry.
25
+ Pure and deterministic: no LLM, no network beyond the cached security registry.
26
26
  """
27
27
 
28
28
  from __future__ import annotations
@@ -183,7 +183,7 @@ def understand(text: str, *, recent: list[str], displays: list[str],
183
183
 
184
184
  ``recent``: symbols charted lately, most recent first (:mod:`recent`).
185
185
  ``displays``: the owner's display names, to split off "where". ``rows``:
186
- the SEC registry, loaded from its cache when not given.
186
+ the security registry, loaded from its cache when not given.
187
187
  """
188
188
  if rows is None:
189
189
  from . import registry
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.3.6
3
+ Version: 0.3.7
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -51,6 +51,7 @@ tests/test_patron.py
51
51
  tests/test_popups.py
52
52
  tests/test_push.py
53
53
  tests/test_recent.py
54
+ tests/test_registry.py
54
55
  tests/test_relay.py
55
56
  tests/test_requestlog.py
56
57
  tests/test_resolve.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "chartremotely"
3
- version = "0.3.6"
3
+ version = "0.3.7"
4
4
  description = "Remote control of desktop charting applications on macOS"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -0,0 +1,119 @@
1
+ """The registry merges SEC issuers with Nasdaq Trader's ETFs.
2
+
3
+ The ETF rows exist because "XBI swing scale" found nothing: the SEC file
4
+ lists a fund only when it files as its own company, and the sector ETFs
5
+ file as a series of a parent trust.
6
+ """
7
+
8
+ import pytest
9
+
10
+ from chartremotely import registry
11
+ from chartremotely.resolve import TICKER, resolve
12
+ from chartremotely.understand import understand
13
+
14
+ # The real directory's header, rows as published, and its closing line.
15
+ DIRECTORY = """\
16
+ Nasdaq Traded|Symbol|Security Name|Listing Exchange|Market Category|ETF|Round Lot Size|Test Issue|Financial Status|CQS Symbol|NASDAQ Symbol|NextShares
17
+ Y|XBI|State Street SPDR S&P Biotech ETF|P| |Y|100|N||XBI|XBI|N
18
+ Y|XLE|State Street Energy Select Sector SPDR ETF|P| |Y|100|N||XLE|XLE|N
19
+ Y|QQQ|Invesco QQQ Trust, Series 1|Q|G|Y|40|N|N||QQQ|N
20
+ Y|SPY|SPDR S&P 500 ETF Trust|P| |Y|100|N||SPY|SPY|N
21
+ Y|STRC|Strategy Inc - Variable Rate Series A Perpetual Stretch Preferred Stock|Q|Q|N|100|N|N||STRC|N
22
+ Y|ZXZZT|NASDAQ TEST STOCK|Q|G|Y|100|Y|N||ZXZZT|N
23
+ N|GONE|Delisted Example ETF|P| |Y|100|N||GONE|GONE|N
24
+ File Creation Time: 1021202609:01|||||||||||
25
+ """
26
+
27
+ SEC = [
28
+ {"t": "NVDA", "n": "NVIDIA CORP", "r": 0},
29
+ {"t": "PLTR", "n": "Palantir Technologies Inc.", "r": 1},
30
+ {"t": "XOM", "n": "EXXON MOBIL CORP", "r": 2},
31
+ {"t": "KRYS", "n": "Krystal Biotech, Inc.", "r": 3},
32
+ {"t": "SPY", "n": "SPDR S&P 500 ETF TRUST", "r": 4},
33
+ ]
34
+
35
+
36
+ def test_only_live_etfs_are_taken():
37
+ assert registry.parse_etfs(DIRECTORY) == [
38
+ ["XBI", "State Street SPDR S&P Biotech ETF"],
39
+ ["XLE", "State Street Energy Select Sector SPDR ETF"],
40
+ ["QQQ", "Invesco QQQ Trust, Series 1"],
41
+ ["SPY", "SPDR S&P 500 ETF Trust"],
42
+ ]
43
+
44
+
45
+ @pytest.mark.parametrize("text", ["", "not a directory\n", "Symbol|Name\nXBI|x\n"])
46
+ def test_an_unexpected_directory_yields_nothing(text):
47
+ assert registry.parse_etfs(text) == []
48
+
49
+
50
+ def test_etfs_rank_after_every_sec_issuer_and_never_duplicate_one():
51
+ rows = registry.merge(SEC, registry.parse_etfs(DIRECTORY))
52
+ assert [r["t"] for r in rows] == ["NVDA", "PLTR", "XOM", "KRYS", "SPY", "XBI", "XLE", "QQQ"]
53
+ assert [r["r"] for r in rows] == list(range(8))
54
+ # The SEC's own SPY row wins over the directory's.
55
+ assert next(r for r in rows if r["t"] == "SPY")["n"] == "SPDR S&P 500 ETF TRUST"
56
+
57
+
58
+ @pytest.fixture
59
+ def caches(tmp_path, monkeypatch):
60
+ monkeypatch.setattr(registry, "DATA_DIR", tmp_path)
61
+ monkeypatch.setattr(registry, "CACHE", tmp_path / "tickers.json")
62
+ monkeypatch.setattr(registry, "ETF_CACHE", tmp_path / "etfs.json")
63
+ monkeypatch.setattr(registry, "refresh", lambda: list(SEC))
64
+ return tmp_path
65
+
66
+
67
+ def test_unreachable_directory_serves_the_last_copy(caches, monkeypatch):
68
+ registry._store(registry.ETF_CACHE, [["XBI", "State Street SPDR S&P Biotech ETF"]])
69
+
70
+ def down():
71
+ raise OSError("nasdaqtrader.com unreachable")
72
+
73
+ monkeypatch.setattr(registry, "refresh_etfs", down)
74
+ rows = registry.load(force=True)
75
+ assert [r["t"] for r in rows][-1] == "XBI"
76
+
77
+
78
+ def test_unreachable_directory_with_no_copy_still_loads_the_sec_file(caches, monkeypatch):
79
+ def down():
80
+ raise OSError("nasdaqtrader.com unreachable")
81
+
82
+ monkeypatch.setattr(registry, "refresh_etfs", down)
83
+ assert registry.load(force=True) == SEC
84
+
85
+
86
+ @pytest.mark.parametrize("shape", [
87
+ "PLTR", "F", "BRK/B", "BTC/USD", "ETH/USD", "$SPX", ".SPX", "/ES", "/ESZ25:XCME", "XBI",
88
+ ])
89
+ def test_ticker_shapes_thinkorswim_shows(shape):
90
+ assert TICKER.match(shape)
91
+
92
+
93
+ @pytest.mark.parametrize("junk", ["", "palantir", "PLTR INC", "12AB", "BTC/USDTXX", "/"])
94
+ def test_not_ticker_shaped(junk):
95
+ assert not TICKER.match(junk)
96
+
97
+
98
+ ROWS = registry.merge(SEC, registry.parse_etfs(DIRECTORY))
99
+
100
+
101
+ @pytest.mark.parametrize("said,expected", [
102
+ ("XBI", "XBI"),
103
+ ("x b i", "XBI"),
104
+ ("ex bee eye", "XBI"),
105
+ ("XLE", "XLE"),
106
+ ("x l e", "XLE"),
107
+ ("q q q", "QQQ"),
108
+ ("energy select sector", "XLE"),
109
+ # Companies still find themselves first.
110
+ ("palantir", "PLTR"),
111
+ ("exxon mobil", "XOM"),
112
+ ])
113
+ def test_etfs_resolve_by_ticker_and_by_name(said, expected):
114
+ assert resolve(said, ROWS) == expected
115
+
116
+
117
+ def test_an_etf_with_a_scale_and_a_display():
118
+ u = understand("XBI swing scale on Mac mini", recent=[], displays=["Mac mini"], rows=ROWS)
119
+ assert (u.ticker, u.scale, u.where) == ("XBI", "swing", "Mac mini")
@@ -1,53 +0,0 @@
1
- """The SEC company registry, cached locally.
2
-
3
- Roughly 10,500 US issuers including ETFs. Ordered by prominence in the
4
- source file, which makes the index a usable tie-breaker: NVDA, AAPL and
5
- GOOGL are the first three entries.
6
- """
7
-
8
- from __future__ import annotations
9
-
10
- import json
11
- import time
12
- import urllib.request
13
-
14
- from .config import DATA_DIR
15
-
16
- SEC_URL = "https://www.sec.gov/files/company_tickers.json"
17
- CACHE = DATA_DIR / "tickers.json"
18
- MAX_AGE = 7 * 24 * 3600
19
- # The SEC requires a User-Agent carrying a contact address and answers 403
20
- # without one. A repository URL alone is not enough.
21
- DEFAULT_CONTACT = "chartremotely@example.com"
22
-
23
-
24
- def _user_agent() -> str:
25
- from .config import load
26
- contact = load().get("contact") or DEFAULT_CONTACT
27
- # SEC's documented format is plain "Name email". Anything fancier is
28
- # rejected: a User-Agent carrying a URL answers 403, as does one with no
29
- # contact address at all.
30
- return f"ChartRemotely {contact}"
31
-
32
-
33
- def _fresh() -> bool:
34
- return CACHE.exists() and time.time() - CACHE.stat().st_mtime < MAX_AGE
35
-
36
-
37
- def refresh() -> list[dict]:
38
- request = urllib.request.Request(SEC_URL, headers={"User-Agent": _user_agent()})
39
- with urllib.request.urlopen(request, timeout=20) as response:
40
- raw = json.load(response)
41
- rows = [{"t": v["ticker"], "n": v["title"], "r": rank}
42
- for rank, v in enumerate(raw.values())]
43
- DATA_DIR.mkdir(parents=True, exist_ok=True)
44
- tmp = CACHE.with_suffix(".tmp")
45
- tmp.write_text(json.dumps(rows))
46
- tmp.replace(CACHE)
47
- return rows
48
-
49
-
50
- def load(force: bool = False) -> list[dict]:
51
- if force or not _fresh():
52
- return refresh()
53
- return json.loads(CACHE.read_text())
File without changes
File without changes
File without changes