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.
- {chartremotely-0.2.11 → chartremotely-0.2.12}/PKG-INFO +15 -2
- {chartremotely-0.2.11 → chartremotely-0.2.12}/README.md +14 -1
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/patron.py +4 -1
- chartremotely-0.2.12/chartremotely/recent.py +50 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/requestlog.py +13 -3
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/resolve.py +99 -7
- chartremotely-0.2.12/chartremotely/scales.py +188 -0
- chartremotely-0.2.12/chartremotely/understand.py +226 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/vocab.py +31 -5
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/PKG-INFO +15 -2
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/SOURCES.txt +6 -1
- {chartremotely-0.2.11 → chartremotely-0.2.12}/pyproject.toml +1 -1
- chartremotely-0.2.12/tests/test_recent.py +30 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_relay.py +1 -1
- {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_requestlog.py +9 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_resolve.py +54 -1
- chartremotely-0.2.12/tests/test_scales.py +70 -0
- chartremotely-0.2.12/tests/test_understand.py +87 -0
- chartremotely-0.2.11/chartremotely/scales.py +0 -77
- {chartremotely-0.2.11 → chartremotely-0.2.12}/LICENSE +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/__init__.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/answer.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/assets/shortcut-template.plist +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/ax.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/cli.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/config.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/doctor.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/guilock.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/keystore.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/layout.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/mcpclient.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/popups.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/push.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/registry.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/relay.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/screen.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/server.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/services.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/setup.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/shortcut.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/snapshot.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/studies.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/symbol.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/tailnet.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/timeframe.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely/window.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/dependency_links.txt +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/entry_points.txt +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/requires.txt +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/chartremotely.egg-info/top_level.txt +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/setup.cfg +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_patron.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_popups.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_push.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_screen.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_setup.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_snapshot.py +0 -0
- {chartremotely-0.2.11 → chartremotely-0.2.12}/tests/test_stamp_changelog.py +0 -0
- {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.
|
|
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,
|
|
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,
|
|
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
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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())
|