chartremotely 0.2.11__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.11 → chartremotely-0.2.12}/PKG-INFO +15 -2
  2. {chartremotely-0.2.11 → chartremotely-0.2.12}/README.md +14 -1
  3. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/patron.py +4 -1
  4. chartremotely-0.2.12/chartremotely/recent.py +50 -0
  5. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/requestlog.py +13 -3
  6. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/resolve.py +99 -7
  7. chartremotely-0.2.12/chartremotely/scales.py +188 -0
  8. chartremotely-0.2.12/chartremotely/understand.py +226 -0
  9. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/vocab.py +31 -5
  10. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/PKG-INFO +15 -2
  11. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/SOURCES.txt +6 -1
  12. {chartremotely-0.2.11 → chartremotely-0.2.12}/pyproject.toml +1 -1
  13. chartremotely-0.2.12/tests/test_recent.py +30 -0
  14. {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_relay.py +1 -1
  15. {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_requestlog.py +9 -0
  16. {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_resolve.py +54 -1
  17. chartremotely-0.2.12/tests/test_scales.py +70 -0
  18. chartremotely-0.2.12/tests/test_understand.py +87 -0
  19. chartremotely-0.2.11/chartremotely/scales.py +0 -77
  20. {chartremotely-0.2.11 → chartremotely-0.2.12}/LICENSE +0 -0
  21. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/__init__.py +0 -0
  22. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/answer.py +0 -0
  23. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/assets/shortcut-template.plist +0 -0
  24. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/ax.py +0 -0
  25. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/cli.py +0 -0
  26. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/config.py +0 -0
  27. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/doctor.py +0 -0
  28. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/guilock.py +0 -0
  29. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/keystore.py +0 -0
  30. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/layout.py +0 -0
  31. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/mcpclient.py +0 -0
  32. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/popups.py +0 -0
  33. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/push.py +0 -0
  34. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/registry.py +0 -0
  35. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/relay.py +0 -0
  36. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/screen.py +0 -0
  37. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/server.py +0 -0
  38. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/services.py +0 -0
  39. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/setup.py +0 -0
  40. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/shortcut.py +0 -0
  41. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/snapshot.py +0 -0
  42. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/studies.py +0 -0
  43. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/symbol.py +0 -0
  44. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/tailnet.py +0 -0
  45. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/timeframe.py +0 -0
  46. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/window.py +0 -0
  47. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/dependency_links.txt +0 -0
  48. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/entry_points.txt +0 -0
  49. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/requires.txt +0 -0
  50. {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/top_level.txt +0 -0
  51. {chartremotely-0.2.11 → chartremotely-0.2.12}/setup.cfg +0 -0
  52. {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_patron.py +0 -0
  53. {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_popups.py +0 -0
  54. {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_push.py +0 -0
  55. {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_screen.py +0 -0
  56. {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_setup.py +0 -0
  57. {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_snapshot.py +0 -0
  58. {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_stamp_changelog.py +0 -0
  59. {chartremotely-0.2.11 → 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.11
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,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
@@ -0,0 +1,188 @@
1
+ """Spoken time-frame vocabulary. Pure logic, no macOS dependency.
2
+
3
+ Kept apart from the driver so it can be imported anywhere - by CI on Linux,
4
+ and by the operator, which needs to validate a spoken phrase before it has
5
+ any agent to send it to.
6
+
7
+ The mnemonics are chosen for phonetic distance rather than literal
8
+ accuracy. Digits are the worst thing to say to a recogniser: "fifteen" and
9
+ "fifty" collide, "one" and "won" collide. None of these ten do.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+
16
+ MNEMONIC = {
17
+ "1d1m": "minute", "5d5m": "scalp", "5d15m": "quarter", "10d30m": "half",
18
+ "20d1h": "hourly", "180d4h": "swing", "1y1d": "daily", "3yw": "weekly",
19
+ "1d133t": "ticks", "1d10t": "micro",
20
+ }
21
+
22
+ ALIAS = {
23
+ "minute": "1d1m", "scalp": "5d5m", "quarter": "5d15m", "half": "10d30m",
24
+ "hourly": "20d1h", "swing": "180d4h", "daily": "1y1d", "weekly": "3yw",
25
+ "ticks": "1d133t", "micro": "1d10t",
26
+ # variants people actually say
27
+ "tick": "1d133t", "ten tick": "1d10t", "quarter hour": "5d15m",
28
+ "a quarter hour": "5d15m", "quarter of an hour": "5d15m",
29
+ "a quarter of an hour": "5d15m",
30
+ "half hour": "10d30m", "half an hour": "10d30m", "half a hour": "10d30m",
31
+ "hour": "20d1h", "an hour": "20d1h", "a hour": "20d1h",
32
+ "day": "1y1d", "a day": "1y1d", "week": "3yw", "a week": "3yw",
33
+ "a minute": "1d1m", "intraday": "1d1m", "swing trade": "180d4h",
34
+ }
35
+
36
+ #: A preset by its bar size alone: "thirty minutes" canon's to "30m", which is
37
+ #: no preset's full code, but it is exactly one preset's bar.
38
+ BAR = {
39
+ "1m": "1d1m", "5m": "5d5m", "15m": "5d15m", "30m": "10d30m",
40
+ "1h": "20d1h", "60m": "20d1h", "4h": "180d4h", "240m": "180d4h",
41
+ "1d": "1y1d", "1w": "3yw", "w": "3yw", "133t": "1d133t", "10t": "1d10t",
42
+ }
43
+
44
+ #: Words said around a scale that name no scale: "at half scale", "the daily chart".
45
+ FILLER = {"at", "on", "the", "scale", "time", "frame", "timeframe", "chart", "bars",
46
+ "bar", "candles", "candle", "please", "use", "make", "it", "set", "to"}
47
+
48
+ AS_IS = {"as is", "as-is", "asis", "same", "leave it", "unchanged",
49
+ "no change", "keep it", "current", "skip", "none", ""}
50
+
51
+ _WORDS = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
52
+ "seven": 7, "eight": 8, "nine": 9, "ten": 10, "fifteen": 15,
53
+ "twenty": 20, "thirty": 30, "sixty": 60, "ninety": 90}
54
+ _UNIT = {"d": "d", "day": "d", "days": "d", "daily": "d", "y": "y",
55
+ "year": "y", "years": "y", "w": "w", "week": "w", "weekly": "w",
56
+ "weeks": "w", "m": "m", "min": "m", "minute": "m", "minutes": "m",
57
+ "h": "h", "hour": "h", "hours": "h", "t": "t", "tick": "t",
58
+ "ticks": "t"}
59
+
60
+
61
+ def canon(text: str) -> str:
62
+ """'5 D : 5m' and 'five day five minute' both give '5d5m'.
63
+
64
+ Returns "" when nothing parses. Callers MUST treat that as a refusal:
65
+ every string ends with "", so a loose endswith match against menu
66
+ labels would otherwise select whichever preset came first.
67
+ """
68
+ s = re.sub(r"[^a-z0-9 ]", " ", text.lower().replace(":", " ").replace("/", " "))
69
+ for word, n in _WORDS.items():
70
+ s = re.sub(rf"\b{word}\b", str(n), s)
71
+ s = re.sub(r"(\d)\s*([a-z])", r"\1\2", s)
72
+ out = []
73
+ for token in s.split():
74
+ m = re.match(r"^(\d+)([a-z]+)$", token)
75
+ if m and m.group(2) in _UNIT:
76
+ out.append(m.group(1) + _UNIT[m.group(2)])
77
+ elif token in _UNIT and out:
78
+ out.append(_UNIT[token])
79
+ return "".join(out)
80
+
81
+
82
+ #: Every word a spoken scale can be made of. A span of an utterance names a
83
+ #: scale only when all its words are these, so "apple one day" is never
84
+ #: read as "one day" with "apple" ignored.
85
+ SCALE_WORDS = ({w for phrase in ALIAS for w in phrase.split()} | set(_WORDS) | set(_UNIT)
86
+ | FILLER | {"an", "of"})
87
+
88
+
89
+ def is_scale_word(word: str) -> bool:
90
+ """True for a word :func:`preset` could use: see :data:`SCALE_WORDS`."""
91
+ return word in SCALE_WORDS or bool(re.fullmatch(r"\d+[a-z]*(\d+[a-z]*)*", word))
92
+
93
+
94
+ def _words(phrase: str) -> str:
95
+ return " ".join(re.sub(r"[^a-z0-9 ]", " ", phrase.lower().replace("-", " ")).split())
96
+
97
+
98
+ def preset(phrase: str) -> str | None:
99
+ """The preset code a phrase names exactly, or None. No guessing.
100
+
101
+ A mnemonic or an alias ("half an hour"), a full code ("10 D 30m"), or a
102
+ bar size alone ("thirty minutes", "4h", "one day"), with filler such as
103
+ "at ... scale" ignored.
104
+ """
105
+ said = _words(phrase)
106
+ bare = " ".join(w for w in said.split() if w not in FILLER)
107
+ for form in (said, bare):
108
+ if form in ALIAS:
109
+ return ALIAS[form]
110
+ if form.replace(" ", "") in MNEMONIC:
111
+ return form.replace(" ", "")
112
+ code = canon(bare)
113
+ if code in MNEMONIC:
114
+ return code
115
+ bar = re.search(r"(\d*[a-z])$", code)
116
+ if bar and re.fullmatch(r"\d*[a-z]", code):
117
+ return BAR.get(bar.group(1))
118
+ return None
119
+
120
+
121
+ def sound_key(word: str) -> str:
122
+ """A deliberately crude phonetic key for one word.
123
+
124
+ ph/gh/v sound as f, c/k/q as k; after the first letter the vowels and
125
+ h/w/y go; repeats collapse. "half", "halve", "haff" and "alpha" all land
126
+ within one edit of "hlf".
127
+ """
128
+ w = re.sub(r"[^a-z]", "", word.lower()).replace("ph", "f").replace("gh", "f")
129
+ w = w.translate(str.maketrans("vcq", "fkk"))
130
+ if not w:
131
+ return ""
132
+ out = [w[0]]
133
+ for ch in w[1:]:
134
+ if ch in "aeiouhwy" or ch == out[-1]:
135
+ continue
136
+ out.append(ch)
137
+ return "".join(out)
138
+
139
+
140
+ def _distance(a: str, b: str) -> int:
141
+ prev = list(range(len(b) + 1))
142
+ for i, ca in enumerate(a, 1):
143
+ cur = [i]
144
+ for j, cb in enumerate(b, 1):
145
+ cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb)))
146
+ prev = cur
147
+ return prev[-1]
148
+
149
+
150
+ KEYS = {sound_key(word): word for word in MNEMONIC.values()}
151
+
152
+
153
+ def sounds_like(word: str) -> str | None:
154
+ """The one mnemonic a mis-heard word is within an edit of, else None.
155
+
156
+ Two candidates within reach is a refusal, not a coin toss: "hal" is one
157
+ edit from half, hourly and daily, so it names none of them.
158
+ """
159
+ if not word.isalpha():
160
+ return None
161
+ key = sound_key(word)
162
+ if len(key) < 2:
163
+ return None
164
+ near = {mn for k, mn in KEYS.items() if _distance(key, k) <= 1}
165
+ return near.pop() if len(near) == 1 else None
166
+
167
+
168
+ def to_code(phrase: str) -> str:
169
+ """The preset code for a phrase, else its raw canon() (possibly "")."""
170
+ return preset(phrase) or canon(phrase)
171
+
172
+
173
+ def mnemonic_for(phrase: str) -> str | None:
174
+ """The word we say back, or None if the phrase names no preset.
175
+
176
+ Exact first (:func:`preset`); a single mis-heard word falls back to
177
+ :func:`sounds_like`.
178
+ """
179
+ code = preset(phrase)
180
+ if code:
181
+ return MNEMONIC[code]
182
+ words = [w for w in _words(phrase).split() if w not in FILLER]
183
+ return sounds_like(words[0]) if len(words) == 1 else None
184
+
185
+
186
+ def spoken_options() -> str:
187
+ """What to read aloud when someone says something unrecognised."""
188
+ return ", ".join(MNEMONIC.values())