chartremotely 0.2.10__tar.gz → 0.2.12__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 (59) hide show
  1. {chartremotely-0.2.10 → chartremotely-0.2.12}/PKG-INFO +15 -2
  2. {chartremotely-0.2.10 → chartremotely-0.2.12}/README.md +14 -1
  3. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/patron.py +4 -1
  4. chartremotely-0.2.12/chartremotely/popups.py +258 -0
  5. chartremotely-0.2.12/chartremotely/recent.py +50 -0
  6. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/requestlog.py +13 -3
  7. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/resolve.py +99 -7
  8. chartremotely-0.2.12/chartremotely/scales.py +188 -0
  9. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/symbol.py +11 -1
  10. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/timeframe.py +8 -7
  11. chartremotely-0.2.12/chartremotely/understand.py +226 -0
  12. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/vocab.py +31 -5
  13. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/window.py +5 -1
  14. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/PKG-INFO +15 -2
  15. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/SOURCES.txt +8 -1
  16. {chartremotely-0.2.10 → chartremotely-0.2.12}/pyproject.toml +1 -1
  17. chartremotely-0.2.12/tests/test_popups.py +132 -0
  18. chartremotely-0.2.12/tests/test_recent.py +30 -0
  19. {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_relay.py +1 -1
  20. {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_requestlog.py +9 -0
  21. {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_resolve.py +54 -1
  22. chartremotely-0.2.12/tests/test_scales.py +70 -0
  23. chartremotely-0.2.12/tests/test_understand.py +87 -0
  24. chartremotely-0.2.10/chartremotely/scales.py +0 -77
  25. {chartremotely-0.2.10 → chartremotely-0.2.12}/LICENSE +0 -0
  26. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/__init__.py +0 -0
  27. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/answer.py +0 -0
  28. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/assets/shortcut-template.plist +0 -0
  29. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/ax.py +0 -0
  30. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/cli.py +0 -0
  31. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/config.py +0 -0
  32. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/doctor.py +0 -0
  33. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/guilock.py +0 -0
  34. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/keystore.py +0 -0
  35. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/layout.py +0 -0
  36. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/mcpclient.py +0 -0
  37. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/push.py +0 -0
  38. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/registry.py +0 -0
  39. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/relay.py +0 -0
  40. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/screen.py +0 -0
  41. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/server.py +0 -0
  42. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/services.py +0 -0
  43. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/setup.py +0 -0
  44. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/shortcut.py +0 -0
  45. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/snapshot.py +0 -0
  46. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/studies.py +0 -0
  47. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely/tailnet.py +0 -0
  48. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/dependency_links.txt +0 -0
  49. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/entry_points.txt +0 -0
  50. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/requires.txt +0 -0
  51. {chartremotely-0.2.10 → chartremotely-0.2.12}/chartremotely.egg-info/top_level.txt +0 -0
  52. {chartremotely-0.2.10 → chartremotely-0.2.12}/setup.cfg +0 -0
  53. {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_patron.py +0 -0
  54. {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_push.py +0 -0
  55. {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_screen.py +0 -0
  56. {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_setup.py +0 -0
  57. {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_snapshot.py +0 -0
  58. {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_stamp_changelog.py +0 -0
  59. {chartremotely-0.2.10 → chartremotely-0.2.12}/tests/test_timeframe.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: chartremotely
3
- Version: 0.2.10
3
+ Version: 0.2.12
4
4
  Summary: Remote control of desktop charting applications on macOS
5
5
  Author: Lonnie VanZandt
6
6
  License:
@@ -345,8 +345,21 @@ expired. Answer the DM on your phone to renew." — a fresh DM is already on its
345
345
  way, and once you answer it (within about half an hour) the next command
346
346
  works again.
347
347
 
348
+ **What Siri heard.** Dictation matches against all of English, so the Mac
349
+ repairs what it hears against the three small vocabularies it actually needs:
350
+ companies in the SEC list, the ten time frames, and your display names. A
351
+ scale may be a mnemonic, a bar size ("thirty minutes", "an hour", "four
352
+ hours", "a day") or one word that sounds like exactly one mnemonic ("have",
353
+ "alf" are half). A company may be mis-heard ("volunteer", "pal and tear" are
354
+ Palantir; "shop if y" is Shopify); when two companies are too close to call
355
+ you are asked - "Did you mean PS (Pershing Square) or MSGS (Madison Square
356
+ Garden)?" - rather than shown the wrong chart, and the symbols you chart
357
+ lately settle a near-tie. `understand()` parses a whole sentence the same way
358
+ ("Palantir half on the mini").
359
+
348
360
  Each command handled is logged as one line — time, the command's first word,
349
- the "Where?" as said, and the reply if it was an ERR — plus one line when its
361
+ the "Where?" as said, what was heard for a company or scale lookup, and the
362
+ reply if it was an ERR — plus one line when its
350
363
  tool call ends (the tool, "Where?" and any refusal) — to
351
364
  `~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
352
365
  (from the operator). Never a token, a secret or a picture.
@@ -121,8 +121,21 @@ expired. Answer the DM on your phone to renew." — a fresh DM is already on its
121
121
  way, and once you answer it (within about half an hour) the next command
122
122
  works again.
123
123
 
124
+ **What Siri heard.** Dictation matches against all of English, so the Mac
125
+ repairs what it hears against the three small vocabularies it actually needs:
126
+ companies in the SEC list, the ten time frames, and your display names. A
127
+ scale may be a mnemonic, a bar size ("thirty minutes", "an hour", "four
128
+ hours", "a day") or one word that sounds like exactly one mnemonic ("have",
129
+ "alf" are half). A company may be mis-heard ("volunteer", "pal and tear" are
130
+ Palantir; "shop if y" is Shopify); when two companies are too close to call
131
+ you are asked - "Did you mean PS (Pershing Square) or MSGS (Madison Square
132
+ Garden)?" - rather than shown the wrong chart, and the symbols you chart
133
+ lately settle a near-tie. `understand()` parses a whole sentence the same way
134
+ ("Palantir half on the mini").
135
+
124
136
  Each command handled is logged as one line — time, the command's first word,
125
- the "Where?" as said, and the reply if it was an ERR — plus one line when its
137
+ the "Where?" as said, what was heard for a company or scale lookup, and the
138
+ reply if it was an ERR — plus one line when its
126
139
  tool call ends (the tool, "Where?" and any refusal) — to
127
140
  `~/Library/Logs/chartremotely-serve.log` (voice) or `chartremotely-relay.log`
128
141
  (from the operator). Never a token, a secret or a picture.
@@ -28,7 +28,7 @@ import threading
28
28
  import time
29
29
  from collections.abc import Callable
30
30
 
31
- from . import config, keystore, mcpclient, requestlog
31
+ from . import config, keystore, mcpclient, recent, requestlog
32
32
 
33
33
  ERR = "ERR "
34
34
  #: How long a spoken reply waits for an early refusal before handing off.
@@ -134,6 +134,9 @@ def send(command: str, where: str, cfg: dict | None = None) -> str:
134
134
  payload = _call(base, tool, envelope)
135
135
  box["payload"] = payload
136
136
  settle(base, npub, tool, wanted, payload)
137
+ if tool == "chart_show_chart" and refusal(payload) is None \
138
+ and payload.get("error_code") not in PROOF_CODES:
139
+ recent.remember(args["security"])
137
140
  done.set()
138
141
 
139
142
  threading.Thread(target=work, name=f"call-{tool}", daemon=True).start()
@@ -0,0 +1,258 @@
1
+ """Close thinkorswim's popups before anyone looks at the chart.
2
+
3
+ Two popups get left open over the chart, and a picture taken then shows them:
4
+
5
+ * the symbol field's autocomplete list (PLTR:XTSE, PLTRX, ...), which hangs
6
+ under the field after a symbol is typed and often outlives the Return that
7
+ committed it;
8
+ * the aggregation (time frame) menu, which stays open after a preset is
9
+ picked or the current one is read.
10
+
11
+ Neither answers the obvious remedies. An Escape posted to thinkorswim lands on
12
+ whatever holds keyboard focus, which after a commit is nothing at all - so it
13
+ is silently dropped. What works, measured on thinkorswim build 1993:
14
+
15
+ * the aggregation menu closes when its toggle is pressed again (``AXPress``);
16
+ the toggle's ``AXValue`` is 1 while the menu shows;
17
+ * the autocomplete list closes on Escape once the symbol field has focus.
18
+ Escape only hides the list: text typed but not yet committed stays in the
19
+ field, so it never cancels an entry;
20
+ * focus leaves the field when the chart's own title strip (the line under
21
+ the field reading "PLTR 10 D 30m") is focused. Setting the field's
22
+ ``AXFocused`` to false is accepted and ignored.
23
+
24
+ A Swing field commits its text when it loses focus, so focus is moved off the
25
+ field only when that text is what the chart already shows. Otherwise the field
26
+ keeps focus and its pending text, and nothing changes.
27
+
28
+ The decisions are pure and live at module scope, over a :class:`Seen`
29
+ snapshot, so they are tested where PyObjC is absent. :func:`dismiss` gathers
30
+ the snapshots and acts; its macOS imports are deferred.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import re
36
+ import time
37
+ from collections.abc import Callable
38
+ from dataclasses import dataclass
39
+
40
+ Rect = tuple[float, float, float, float]
41
+
42
+ #: Longest dismiss() spends closing popups.
43
+ TIMEOUT = 1.5
44
+ #: How often dismiss() looks again after acting.
45
+ POLL = 0.1
46
+ #: Longest dismiss() waits for one action to show before trying it again.
47
+ ACT_WAIT = 0.5
48
+ #: Points of slack when deciding a list hangs from the symbol field.
49
+ SLACK = 8
50
+ #: How far under the field's bottom edge to hit-test for a list or the title.
51
+ PROBE_DY, PROBE_DX = 8, 8
52
+
53
+ #: The first word of the chart's title strip: the symbol it is drawn for.
54
+ CHART_SYMBOL = re.compile(r"^[./$^]?[A-Z0-9]{1,12}([/.][A-Z0-9]{1,3})?(:[A-Z]+)?$")
55
+
56
+ CLOSE_SCALE, CLOSE_LIST, LEAVE_FIELD = "close scale menu", "close symbol list", "leave field"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Seen:
61
+ """What the chart's popups and symbol field looked like at one moment."""
62
+
63
+ scale_open: bool = False
64
+ list_open: bool = False
65
+ field_focused: bool = False
66
+ field_value: str | None = None
67
+ #: The chart's title strip, e.g. "PLTR 10 D 30m"; None while a list covers it.
68
+ title: str | None = None
69
+
70
+ @property
71
+ def clean(self) -> bool:
72
+ """Nothing is drawn over the chart."""
73
+ return not (self.scale_open or self.list_open)
74
+
75
+
76
+ def hangs_under(field: Rect, table: Rect | None) -> bool:
77
+ """Whether ``table`` is the autocomplete list dropped from the symbol field.
78
+
79
+ thinkorswim paints that list inside the window, so the window server never
80
+ sees it; the accessibility tree reports it as a table whose top-left corner
81
+ sits on the field's bottom-left corner. ``field`` is the field's combo box.
82
+ """
83
+ if table is None:
84
+ return False
85
+ fx, fy, _, fh = field
86
+ tx, ty, tw, th = table
87
+ return (tw > 0 and th > 0 and abs(tx - fx) <= SLACK
88
+ and abs(ty - (fy + fh)) <= SLACK)
89
+
90
+
91
+ def chart_symbol(title: str | None) -> str | None:
92
+ """The symbol a chart title strip names ("PLTR 10 D 30m" -> "PLTR"), or None."""
93
+ if not isinstance(title, str) or not title.strip():
94
+ return None
95
+ word = title.split()[0]
96
+ return word if CHART_SYMBOL.match(word) else None
97
+
98
+
99
+ def shows(title: str | None, entry: str | None) -> bool:
100
+ """Whether the chart already shows what ``entry`` names.
101
+
102
+ A futures root typed as "/ES" is drawn as its front contract ("/ESZ26"),
103
+ so a root counts as shown by any of its contracts.
104
+ """
105
+ drawn = chart_symbol(title)
106
+ want = (entry or "").strip().upper()
107
+ if not drawn or not want:
108
+ return False
109
+ return drawn == want or (want.startswith("/") and drawn.startswith(want))
110
+
111
+
112
+ def next_step(seen: Seen) -> str | None:
113
+ """The one thing to do next, or None when there is nothing safe left to do.
114
+
115
+ The menu is closed before the list: its toggle is pressed through the
116
+ tree, which needs no focus, whereas closing the list moves focus into the
117
+ field. Focus leaves the field only when the chart already shows its text,
118
+ because losing focus commits whatever the field holds.
119
+ """
120
+ if seen.scale_open:
121
+ return CLOSE_SCALE
122
+ if seen.list_open:
123
+ return CLOSE_LIST
124
+ if seen.field_focused and shows(seen.title, seen.field_value):
125
+ return LEAVE_FIELD
126
+ return None
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class Dismissed:
131
+ """What dismiss() did, and whether the chart was left uncovered."""
132
+
133
+ did: tuple[str, ...] = ()
134
+ clean: bool = True
135
+
136
+ def __bool__(self) -> bool:
137
+ return bool(self.did)
138
+
139
+ def __str__(self) -> str:
140
+ done = "; ".join(self.did) or "nothing open"
141
+ return done if self.clean else done + "; a popup is still open"
142
+
143
+
144
+ def run(look: Callable[[], Seen], act: Callable[[str], None], *,
145
+ until: Callable[[Seen], bool] | None = None, timeout: float = TIMEOUT,
146
+ clock: Callable[[], float] = time.monotonic,
147
+ sleep: Callable[[float], None] = time.sleep) -> Dismissed:
148
+ """Look, act on the next step, and look again until nothing is left.
149
+
150
+ ``until``, when given, must also hold before it stops early: a symbol just
151
+ committed is waited for until the chart's title names it, so focus can
152
+ leave the field once it is safe to. One action is retried only after it
153
+ has had ACT_WAIT to show, so a slow repaint is not answered with a second
154
+ press of a toggle.
155
+ """
156
+ deadline = clock() + timeout
157
+ did: list[str] = []
158
+ last: tuple[str | None, float] = (None, -ACT_WAIT)
159
+ while True:
160
+ seen = look()
161
+ step = next_step(seen)
162
+ now = clock()
163
+ if step is None and (until is None or until(seen)) or now >= deadline:
164
+ return Dismissed(tuple(did), seen.clean)
165
+ if step is not None and (step != last[0] or now - last[1] >= ACT_WAIT):
166
+ act(step)
167
+ if step not in did:
168
+ did.append(step)
169
+ last = (step, now)
170
+ sleep(POLL)
171
+
172
+
173
+ # --------------------------------------------------------------------------
174
+ # macOS. Imported lazily so the decisions above stay importable anywhere.
175
+
176
+
177
+ def _frame(ax, element) -> Rect | None:
178
+ p, s = ax.position(element), ax.size(element)
179
+ return (p.x, p.y, s.width, s.height) if p and s else None
180
+
181
+
182
+ def _under_field(ax, ax_app, field):
183
+ """The combo's frame, the element just under it, and the list that is, if any."""
184
+ combo = ax.attr(field, "AXParent")
185
+ frame = _frame(ax, combo) if combo is not None else None
186
+ if frame is None:
187
+ return None, None, None
188
+ x, y, _, h = frame
189
+ hit = ax.element_at(ax_app, x + PROBE_DX, y + h + PROBE_DY)
190
+ table = None
191
+ for element in (hit, ax.attr(hit, "AXParent") if hit is not None else None):
192
+ if element is not None and ax.attr(element, "AXRole") == "AXTable":
193
+ table = element
194
+ break
195
+ return frame, hit, table
196
+
197
+
198
+ def dismiss(app=None, expect: str | None = None, timeout: float = TIMEOUT) -> Dismissed:
199
+ """Close the symbol list and the scale menu, and take focus off the field.
200
+
201
+ ``expect`` is a symbol just committed: dismiss() then also waits, within
202
+ ``timeout``, for the chart's title to name it before it lets focus go.
203
+
204
+ Idempotent, and cheap when nothing is open: a few accessibility reads.
205
+ Call it with thinkorswim in front (hit-testing answers from the menu bar
206
+ otherwise), inside the chart lock, after the change it tidies up after.
207
+ A field or toggle that cannot be found is left alone rather than raised
208
+ about: tidying up must never turn a change that landed into an error.
209
+ """
210
+ from . import ax, config, symbol, timeframe
211
+
212
+ app = app or ax.running_app()
213
+ pid = app.processIdentifier()
214
+ ax_app = ax.handle(app)
215
+ cfg = config.load()
216
+ try:
217
+ field, _ = symbol.locate(ax_app, cfg)
218
+ except symbol.NotFound:
219
+ field = None
220
+ try:
221
+ toggle, _ = timeframe._control(ax_app, cfg)
222
+ except timeframe.MenuError:
223
+ toggle = None
224
+ title = [None]
225
+
226
+ def look() -> Seen:
227
+ scale_open = toggle is not None and ax.attr(toggle, "AXValue") not in (0, "0", False, None)
228
+ if field is None:
229
+ return Seen(scale_open=scale_open)
230
+ frame, hit, table = _under_field(ax, ax_app, field)
231
+ list_open = frame is not None and hangs_under(
232
+ frame, _frame(ax, table) if table is not None else None)
233
+ title[0] = None if list_open else hit
234
+ return Seen(
235
+ scale_open=scale_open,
236
+ list_open=list_open,
237
+ field_focused=bool(ax.attr(field, "AXFocused")),
238
+ field_value=ax.attr(field, "AXValue"),
239
+ title=ax.attr(title[0], "AXValue") if title[0] is not None else None,
240
+ )
241
+
242
+ def act(step: str) -> None:
243
+ if step == CLOSE_SCALE:
244
+ ax.perform(toggle, "AXPress")
245
+ elif step == CLOSE_LIST:
246
+ # Escape reaches only the focused component; after a commit that
247
+ # is nothing, so the field is focused first.
248
+ if not ax.attr(field, "AXFocused"):
249
+ ax.set_attr(field, "AXFocused", True)
250
+ time.sleep(0.1)
251
+ ax.escape(pid)
252
+ elif step == LEAVE_FIELD:
253
+ # LEAVE_FIELD is only chosen when the element under the field
254
+ # read as the chart's title, so title[0] is that strip.
255
+ ax.set_attr(title[0], "AXFocused", True)
256
+
257
+ until = (lambda seen: shows(seen.title, expect)) if expect else None
258
+ return run(look, act, until=until, timeout=timeout)
@@ -0,0 +1,50 @@
1
+ """The symbols this Mac charted lately: a personal prior for what was said.
2
+
3
+ When a spoken name lands between two companies, the one you chart is the one
4
+ you meant (see :func:`resolve.decide`). The list is kept most recent first,
5
+ at most :data:`KEEP` long, in ``~/.config/chartremotely/recent.json``, and is
6
+ written only when a chart change succeeded. It holds ticker symbols and
7
+ nothing else - never a token, a name as said or a secret.
8
+
9
+ Stdlib only; every function swallows its own errors, because a lost prior
10
+ must never cost a reply.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import re
17
+
18
+ from .config import CONFIG_DIR
19
+
20
+ PATH = CONFIG_DIR / "recent.json"
21
+ KEEP = 50
22
+ _TICKER = re.compile(r"^[A-Z][A-Z0-9.\-]{0,9}$")
23
+
24
+
25
+ def load(path=None) -> list[str]:
26
+ """The remembered symbols, most recent first; [] when there are none."""
27
+ try:
28
+ data = json.loads((path or PATH).read_text())
29
+ except (OSError, ValueError):
30
+ return []
31
+ if not isinstance(data, list):
32
+ return []
33
+ return [t for t in data if isinstance(t, str) and _TICKER.match(t)][:KEEP]
34
+
35
+
36
+ def remember(ticker: str, path=None) -> None:
37
+ """Put a symbol that was just charted at the front. Ignores anything not
38
+ shaped like a ticker, so a company name as said is never written."""
39
+ ticker = (ticker or "").strip().upper()
40
+ if not _TICKER.match(ticker):
41
+ return
42
+ path = path or PATH
43
+ kept = [ticker] + [t for t in load(path) if t != ticker]
44
+ try:
45
+ path.parent.mkdir(parents=True, exist_ok=True)
46
+ tmp = path.with_suffix(".tmp")
47
+ tmp.write_text(json.dumps(kept[:KEEP]))
48
+ tmp.replace(path)
49
+ except OSError:
50
+ pass
@@ -2,8 +2,12 @@
2
2
 
3
3
  launchd sends stderr to ``~/Library/Logs/chartremotely-<service>.log``. The
4
4
  line carries only what is needed to tell requests apart: the time, the verb
5
- (the command's first word, never its arguments), the "Where?" as dictated,
6
- and the reply when it is an ERR. Never a token, a secret or a picture.
5
+ (the command's first word), the "Where?" as dictated, and the reply when it
6
+ is an ERR. A command's arguments are never logged - except for the free
7
+ ``resolve`` and ``scale`` lookups, whose argument is what Siri heard for a
8
+ company or a time frame (``heard=``, at most :data:`HEARD_MAX` characters):
9
+ not sensitive, and the only way to see why a word was not understood.
10
+ Never a token, a secret or a picture.
7
11
 
8
12
  Stdlib only, like the relay, so it imports where the macOS drivers do not.
9
13
  """
@@ -15,6 +19,9 @@ import time
15
19
 
16
20
  WHERE_MAX = 64
17
21
  ERR_MAX = 200
22
+ HEARD_MAX = 60
23
+ #: The verbs whose argument is logged as ``heard=``; see the module doc.
24
+ HEARD_VERBS = frozenset({"resolve", "scale"})
18
25
 
19
26
 
20
27
  def _clean(text: str, limit: int) -> str:
@@ -25,9 +32,12 @@ def _clean(text: str, limit: int) -> str:
25
32
 
26
33
  def line(source: str, command: str, where: str, reply: str) -> str:
27
34
  """The log line for one request (see the module doc)."""
28
- verb = _clean((command or "").split(maxsplit=1)[0] if (command or "").strip() else "-", 32)
35
+ first, _, rest = (command or "").strip().partition(" ")
36
+ verb = _clean(first or "-", 32)
29
37
  parts = [time.strftime("%Y-%m-%dT%H:%M:%S%z"), source, f"verb={verb}",
30
38
  f"where={_clean(where or '', WHERE_MAX)!r}"]
39
+ if first.lower() in HEARD_VERBS:
40
+ parts.append(f"heard={_clean(rest.strip(), HEARD_MAX)!r}")
31
41
  if (reply or "").startswith("ERR"):
32
42
  parts.append(f"reply={_clean(reply, ERR_MAX)!r}")
33
43
  return " ".join(parts)
@@ -15,14 +15,22 @@ Matching runs in tiers, highest score wins:
15
15
  650 the query COVERS the name, plus extra words ("john deere")
16
16
  600 consonant skeletons match ("volunteer")
17
17
  400 fuzzy, as a last resort
18
+
19
+ :func:`decide` sits on top: it also scores the words run together ("shop if
20
+ y" is "shopify"), treats share classes of one company as one answer, lets
21
+ the symbols this Mac charted recently settle a near-tie, and - when two
22
+ different companies are still too close to call - returns both, so the
23
+ reply can ask "Did you mean ...?" instead of guessing. A confident wrong
24
+ ticker is worse than a question.
18
25
  """
19
26
 
20
27
  from __future__ import annotations
21
28
 
22
29
  import difflib
23
30
  import re
31
+ from dataclasses import dataclass, field
24
32
 
25
- __all__ = ["candidates", "normalize", "phonetic", "resolve", "spelled"]
33
+ __all__ = ["Decision", "candidates", "decide", "normalize", "phonetic", "resolve", "spelled"]
26
34
 
27
35
  # Corporate furniture, stripped from both sides before comparison. "and"
28
36
  # is here because "&" normalises to it, so DEERE & CO reduces to "deere".
@@ -58,6 +66,8 @@ ALIASES = {
58
66
  "callalon": "QCOM", # nasal "Qualcomm"
59
67
  "ge aerospace": "GE", # still filed as GENERAL ELECTRIC CO
60
68
  "ge aviation": "GE",
69
+ "google": "GOOGL", # filed as Alphabet Inc.
70
+ "facebook": "META", # filed as Meta Platforms
61
71
  }
62
72
 
63
73
  # Soundex-style consonant coding: vowels carry almost no information in a
@@ -85,6 +95,16 @@ MIN_PREFIX = 4
85
95
  MIN_FUZZY = 5
86
96
  FUZZY_FLOOR = 0.82
87
97
  NAME_COVERAGE = 0.6 # how much of the company name the query must cover
98
+ #: Two different companies scoring within this of each other is a question.
99
+ MARGIN = 10.0
100
+ #: A symbol charted recently wins when it scores within this of the best -
101
+ #: unless the best is the ticker itself or the exact company name.
102
+ PRIOR_MARGIN = 25.0
103
+ CERTAIN = 900.0
104
+ #: A close-scoring name far less prominent than the best one (by its place
105
+ #: in the SEC file) is no real contender: "robinhood" is HOOD, not a fund.
106
+ def _contends(rank: int, top_rank: int) -> bool:
107
+ return rank <= 4 * top_rank + 400
88
108
 
89
109
 
90
110
  def normalize(text: str) -> str:
@@ -195,13 +215,85 @@ def candidates(query: str, rows: list[dict]) -> list[tuple[float, str, str]]:
195
215
  return out
196
216
 
197
217
 
198
- def resolve(query: str, rows: list[dict]) -> str | None:
199
- """Best ticker for a spoken query, or None when nothing is convincing."""
218
+ @dataclass(frozen=True)
219
+ class Decision:
220
+ """What a spoken company name came to.
221
+
222
+ ``ticker`` is set only when the answer is clear. ``options`` holds the
223
+ (ticker, spoken name) pairs too close to call - never set together with
224
+ ``ticker``. Neither set means nothing matched.
225
+ """
226
+
227
+ ticker: str | None = None
228
+ options: list[tuple[str, str]] = field(default_factory=list)
229
+
230
+ def question(self) -> str:
231
+ """'Did you mean PLTR (Palantir Technologies) or FLUT (Flutter Entertainment)?'"""
232
+ said = [f"{t} ({n})" for t, n in self.options]
233
+ return "Did you mean " + ", ".join(said[:-1]) + " or " + said[-1] + "?"
234
+
235
+
236
+ def spoken_name(name: str) -> str:
237
+ """'PALANTIR TECHNOLOGIES INC.' -> 'Palantir Technologies' (three words at most)."""
238
+ words = strip_suffixes(normalize(name)).split()[:3]
239
+ return " ".join(words).title()
240
+
241
+
242
+ def _ranked(query: str, rows: list[dict]) -> list[tuple[float, str, str]]:
243
+ """Candidates for the query and for its words run together, best per ticker."""
244
+ best: dict[str, tuple[float, str, str]] = {}
245
+ forms = [query]
246
+ # "and" stays in: "pal and tear" run together sounds like Palantir. Words
247
+ # that are all filler are still refused, run together or not.
248
+ words = normalize(query).split()
249
+ if len(words) > 1 and set(words) - STOPWORDS:
250
+ forms.append("".join(words))
251
+ for form in forms:
252
+ for cand in candidates(form, rows):
253
+ if cand[1] not in best or cand[0] > best[cand[1]][0]:
254
+ best[cand[1]] = cand
255
+ return sorted(best.values(), key=lambda c: -c[0])
256
+
257
+
258
+ def decide(query: str, rows: list[dict], recent: list[str] | tuple = ()) -> Decision:
259
+ """The ticker for a spoken name, the options to ask about, or nothing.
260
+
261
+ ``recent`` is the symbols this Mac charted lately, most recent first;
262
+ it only ever settles a near-tie (see :data:`PRIOR_MARGIN`).
263
+ """
200
264
  letters = spelled(query)
201
265
  if letters and any(r["t"] == letters for r in rows):
202
- return letters
266
+ return Decision(letters)
203
267
  direct = ALIASES.get(normalize(query))
204
268
  if direct:
205
- return direct
206
- ranked = candidates(query, rows)
207
- return ranked[0][1] if ranked else None
269
+ return Decision(direct)
270
+ ranks = {r["t"]: r.get("r", 0) for r in rows}
271
+ ranked = _ranked(query, rows)
272
+ if not ranked:
273
+ return Decision()
274
+ # One company, many share classes (F, F-PB, F-PC...): one answer, the best.
275
+ companies: list[tuple[float, str, str]] = []
276
+ seen: set[str] = set()
277
+ for cand in ranked:
278
+ key = strip_suffixes(normalize(cand[2]))
279
+ if key not in seen:
280
+ seen.add(key)
281
+ companies.append(cand)
282
+ top = companies[0][0]
283
+ order = {t.upper(): i for i, t in reversed(list(enumerate(recent)))}
284
+ if top < CERTAIN:
285
+ known = [c for c in companies if c[1] in order and top - c[0] <= PRIOR_MARGIN]
286
+ if known:
287
+ return Decision(min(known, key=lambda c: order[c[1]])[1])
288
+ top_rank = ranks.get(companies[0][1], 0)
289
+ close = [c for c in companies if top - c[0] <= MARGIN
290
+ and (c is companies[0] or _contends(ranks.get(c[1], 0), top_rank))]
291
+ if len(close) == 1:
292
+ return Decision(close[0][1])
293
+ return Decision(options=[(t, spoken_name(n)) for _, t, n in close[:3]])
294
+
295
+
296
+ def resolve(query: str, rows: list[dict], recent: list[str] | tuple = ()) -> str | None:
297
+ """Best ticker for a spoken query, or None when nothing is convincing or
298
+ the answer is too close to call (see :func:`decide`)."""
299
+ return decide(query, rows, recent).ticker