chartremotely 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chartremotely/__init__.py +0 -0
- chartremotely/assets/shortcut-template.plist +1226 -0
- chartremotely/ax.py +278 -0
- chartremotely/cli.py +136 -0
- chartremotely/config.py +68 -0
- chartremotely/doctor.py +118 -0
- chartremotely/keystore.py +101 -0
- chartremotely/layout.py +106 -0
- chartremotely/mcpclient.py +98 -0
- chartremotely/push.py +58 -0
- chartremotely/registry.py +53 -0
- chartremotely/relay.py +145 -0
- chartremotely/resolve.py +207 -0
- chartremotely/scales.py +77 -0
- chartremotely/server.py +69 -0
- chartremotely/services.py +116 -0
- chartremotely/setup.py +202 -0
- chartremotely/shortcut.py +56 -0
- chartremotely/snapshot.py +88 -0
- chartremotely/studies.py +265 -0
- chartremotely/symbol.py +160 -0
- chartremotely/tailnet.py +51 -0
- chartremotely/timeframe.py +210 -0
- chartremotely/vocab.py +140 -0
- chartremotely/window.py +134 -0
- chartremotely-0.2.0.dist-info/METADATA +381 -0
- chartremotely-0.2.0.dist-info/RECORD +31 -0
- chartremotely-0.2.0.dist-info/WHEEL +5 -0
- chartremotely-0.2.0.dist-info/entry_points.txt +2 -0
- chartremotely-0.2.0.dist-info/licenses/LICENSE +202 -0
- chartremotely-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Make this Mac's own copy of the ChartRemotely voice Shortcut.
|
|
2
|
+
|
|
3
|
+
The template is the working Shortcut itself, exported with two placeholders
|
|
4
|
+
where its address and token were. Filling them in is string substitution
|
|
5
|
+
and nothing more: rebuilding a Shortcut's steps by hand has twice produced
|
|
6
|
+
one that Siri runs differently from the original.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import plistlib
|
|
12
|
+
import subprocess
|
|
13
|
+
import tempfile
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
URL_MARK = "__CHARTREMOTELY_URL__"
|
|
18
|
+
TOKEN_MARK = "__CHARTREMOTELY_TOKEN__"
|
|
19
|
+
NAME = "ChartRemotely"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def template() -> bytes:
|
|
23
|
+
return resources.files("chartremotely").joinpath("assets/shortcut-template.plist").read_bytes()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fill(raw: bytes, url: str, token: str) -> dict:
|
|
27
|
+
"""The template with its placeholders replaced, as a Shortcut workflow."""
|
|
28
|
+
for value, mark in ((url, URL_MARK), (token, TOKEN_MARK)):
|
|
29
|
+
if not value or mark in value:
|
|
30
|
+
raise ValueError(f"no value for {mark}")
|
|
31
|
+
workflow = plistlib.loads(raw)
|
|
32
|
+
|
|
33
|
+
def walk(node):
|
|
34
|
+
if isinstance(node, dict):
|
|
35
|
+
return {k: walk(v) for k, v in node.items()}
|
|
36
|
+
if isinstance(node, list):
|
|
37
|
+
return [walk(v) for v in node]
|
|
38
|
+
if isinstance(node, str):
|
|
39
|
+
return node.replace(URL_MARK, url).replace(TOKEN_MARK, token)
|
|
40
|
+
return node
|
|
41
|
+
|
|
42
|
+
return walk(workflow)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def build(url: str, token: str, out_dir: Path | None = None) -> Path:
|
|
46
|
+
"""Write, sign and return this Mac's Shortcut. The file name is its library name."""
|
|
47
|
+
folder = Path(out_dir or tempfile.mkdtemp(prefix="chartremotely-"))
|
|
48
|
+
unsigned = folder / "unsigned.shortcut"
|
|
49
|
+
signed = folder / f"{NAME}.shortcut"
|
|
50
|
+
unsigned.write_bytes(plistlib.dumps(fill(template(), url, token), fmt=plistlib.FMT_BINARY))
|
|
51
|
+
try:
|
|
52
|
+
subprocess.run(["shortcuts", "sign", "-m", "anyone", "-i", str(unsigned), "-o", str(signed)],
|
|
53
|
+
capture_output=True, check=True)
|
|
54
|
+
finally:
|
|
55
|
+
unsigned.unlink(missing_ok=True)
|
|
56
|
+
return signed
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Crop a capture to the chart, and shrink it into something that can travel.
|
|
2
|
+
|
|
3
|
+
Only the chart's own pane leaves the machine. A thinkorswim window also shows
|
|
4
|
+
the account number, net liquidation value, buying power and cash, and a
|
|
5
|
+
snapshot is a picture sent to wherever the patron is — so the crop is the
|
|
6
|
+
point, not a nicety. When the chart's pane cannot be told apart from the whole
|
|
7
|
+
window, there is no snapshot: failing closed is the only safe answer.
|
|
8
|
+
|
|
9
|
+
A window capture on a Retina display is a multi-megabyte PNG. It crosses the
|
|
10
|
+
relay as text inside a JSON reply and sits for a moment in the operator's
|
|
11
|
+
database, so it is re-encoded as a modest JPEG first: enough to read the
|
|
12
|
+
chart, small enough not to matter.
|
|
13
|
+
|
|
14
|
+
Pure on purpose. The capture itself needs the macOS drivers; turning bytes
|
|
15
|
+
into a reply does not, so this module stays importable - and testable -
|
|
16
|
+
where PyObjC is absent. ``sips`` is the system's own image tool, which keeps
|
|
17
|
+
the dependency list empty.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import base64
|
|
23
|
+
import subprocess
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
#: Longest side of the image sent back, in pixels.
|
|
28
|
+
MAX_SIDE = 1600
|
|
29
|
+
#: JPEG quality, 0-100.
|
|
30
|
+
QUALITY = 70
|
|
31
|
+
#: What every successful reply starts with. The operator checks for it.
|
|
32
|
+
PREFIX = "data:image/jpeg;base64,"
|
|
33
|
+
|
|
34
|
+
Rect = tuple[int, int, int, int]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ChartNotIsolated(LookupError):
|
|
38
|
+
"""The chart's own pane could not be told apart from the whole window."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def chart_crop(hit: tuple[int, int], panes: list[Rect], window: Rect,
|
|
42
|
+
image: tuple[int, int]) -> Rect:
|
|
43
|
+
"""The chart's pane, in the capture's own pixels.
|
|
44
|
+
|
|
45
|
+
``hit`` is a point inside the chart (its symbol field), ``panes`` the
|
|
46
|
+
containers the accessibility tree reports and ``window`` the window, all
|
|
47
|
+
in screen points. ``image`` is the capture's size in pixels, which differs
|
|
48
|
+
from the window's size in points on a Retina display.
|
|
49
|
+
|
|
50
|
+
The chart's pane is the smallest container around the symbol field. The
|
|
51
|
+
window itself never counts: sending it would send the account panel.
|
|
52
|
+
"""
|
|
53
|
+
wx, wy, ww, wh = window
|
|
54
|
+
hx, hy = hit
|
|
55
|
+
inside = [r for r in panes
|
|
56
|
+
if r[0] <= hx < r[0] + r[2] and r[1] <= hy < r[1] + r[3]
|
|
57
|
+
and r[2] * r[3] < ww * wh]
|
|
58
|
+
if not inside:
|
|
59
|
+
raise ChartNotIsolated("could not tell the chart apart from the rest of the window")
|
|
60
|
+
x, y, w, h = min(inside, key=lambda r: r[2] * r[3])
|
|
61
|
+
sx, sy = image[0] / ww, image[1] / wh
|
|
62
|
+
left, top = max(0, round((x - wx) * sx)), max(0, round((y - wy) * sy))
|
|
63
|
+
right = min(image[0], round((x - wx + w) * sx))
|
|
64
|
+
bottom = min(image[1], round((y - wy + h) * sy))
|
|
65
|
+
return left, top, right - left, bottom - top
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def sips_argv(src: Path, dst: Path, max_side: int = MAX_SIDE,
|
|
69
|
+
quality: int = QUALITY) -> list[str]:
|
|
70
|
+
"""The ``sips`` command that resizes and re-encodes one capture."""
|
|
71
|
+
return ["sips", "-Z", str(max_side),
|
|
72
|
+
"-s", "format", "jpeg",
|
|
73
|
+
"-s", "formatOptions", str(quality),
|
|
74
|
+
str(src), "--out", str(dst)]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def as_reply(jpeg: bytes) -> str:
|
|
78
|
+
"""A JPEG, as the one-line text reply the relay carries."""
|
|
79
|
+
return PREFIX + base64.b64encode(jpeg).decode("ascii")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def shrink(png: bytes) -> bytes:
|
|
83
|
+
"""Re-encode a PNG capture as a downscaled JPEG."""
|
|
84
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
85
|
+
src, dst = Path(tmp) / "chart.png", Path(tmp) / "chart.jpg"
|
|
86
|
+
src.write_bytes(png)
|
|
87
|
+
subprocess.run(sips_argv(src, dst), check=True, capture_output=True)
|
|
88
|
+
return dst.read_bytes()
|
chartremotely/studies.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Manage the chart's studies.
|
|
2
|
+
|
|
3
|
+
Not on the voice path - this is the one-time setup that puts a useful chart
|
|
4
|
+
on the wall. It drives thinkorswim's Edit Studies dialog, which behaves
|
|
5
|
+
differently from the chart itself in several ways worth knowing:
|
|
6
|
+
|
|
7
|
+
* the Studies toolbar control is a toggle that can be left "on" with no menu
|
|
8
|
+
showing, so a blind press closes it instead of opening it
|
|
9
|
+
* its menu items answer to ``AXPick``
|
|
10
|
+
* the study list rows answer to NEITHER press nor keyboard - selecting one
|
|
11
|
+
needs a real click, and "Add selected" stays greyed until something is
|
|
12
|
+
genuinely selected
|
|
13
|
+
* button captions live in ``AXDescription``, not ``AXTitle``
|
|
14
|
+
* its combo boxes are not settable, but accept type-ahead once focused:
|
|
15
|
+
send "d" and CHART becomes DAY
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
from . import ax, config, layout
|
|
23
|
+
|
|
24
|
+
DIALOG = "Edit Studies and Strategies"
|
|
25
|
+
|
|
26
|
+
# A chart for reading structure: where price traded, whether volatility is
|
|
27
|
+
# compressed, and the session's volume-weighted mean.
|
|
28
|
+
PRESET = (
|
|
29
|
+
("VolumeProfile", None),
|
|
30
|
+
("squeeze", "TTM_Squeeze"),
|
|
31
|
+
("vwap", "VWAP"), # the period-resetting one, NOT AnchoredVWAP
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DialogError(RuntimeError):
|
|
36
|
+
"""The studies dialog could not be opened or driven."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _dialog(ax_app):
|
|
40
|
+
for window in ax.attr(ax_app, "AXWindows") or []:
|
|
41
|
+
if (ax.attr(window, "AXTitle") or "") == DIALOG:
|
|
42
|
+
return window
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _labelled(element, caption: str) -> bool:
|
|
47
|
+
return caption in (ax.attr(element, "AXTitle"), ax.attr(element, "AXDescription"))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _button(root, caption: str):
|
|
51
|
+
hits = ax.find(root, role="AXButton", predicate=lambda el: _labelled(el, caption))
|
|
52
|
+
return hits[0] if hits else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def open_dialog(app=None):
|
|
56
|
+
"""Studies toolbar -> "Edit studies...". Returns the dialog element."""
|
|
57
|
+
app = app or ax.running_app()
|
|
58
|
+
ax.activate(app)
|
|
59
|
+
ax_app = ax.handle(app)
|
|
60
|
+
if _dialog(ax_app) is not None:
|
|
61
|
+
return _dialog(ax_app)
|
|
62
|
+
|
|
63
|
+
prefix = config.load()["window_prefix"]
|
|
64
|
+
spot = layout.find_in_panes(
|
|
65
|
+
ax_app, prefix,
|
|
66
|
+
lambda el: el is not None and ax.attr(el, "AXRole") == "AXCheckBox"
|
|
67
|
+
and _labelled(el, "Studies"))
|
|
68
|
+
if spot is None:
|
|
69
|
+
raise DialogError("Studies control not found; is a chart visible?")
|
|
70
|
+
|
|
71
|
+
toggle = ax.element_at(ax_app, *spot)
|
|
72
|
+
# Cycle a stuck-open toggle: pressing it while already "on" would close
|
|
73
|
+
# the menu, and the stale items left behind do not respond to AXPick.
|
|
74
|
+
if ax.attr(toggle, "AXValue") in (0, "0", False, None):
|
|
75
|
+
ax.perform(toggle, "AXPress")
|
|
76
|
+
time.sleep(1.4)
|
|
77
|
+
else:
|
|
78
|
+
ax.perform(toggle, "AXPress")
|
|
79
|
+
time.sleep(0.8)
|
|
80
|
+
ax.perform(toggle, "AXPress")
|
|
81
|
+
time.sleep(1.4)
|
|
82
|
+
|
|
83
|
+
item = None
|
|
84
|
+
for window in ax.attr(ax_app, "AXWindows") or []:
|
|
85
|
+
for candidate in ax.find(window, role="AXMenuItem"):
|
|
86
|
+
label = (ax.attr(candidate, "AXTitle") or ax.attr(candidate, "AXValue")
|
|
87
|
+
or ax.attr(candidate, "AXDescription") or "")
|
|
88
|
+
if str(label).strip().lower().startswith("edit studies"):
|
|
89
|
+
item = candidate
|
|
90
|
+
break
|
|
91
|
+
if item:
|
|
92
|
+
break
|
|
93
|
+
if item is None:
|
|
94
|
+
raise DialogError("'Edit studies...' not found in the Studies menu")
|
|
95
|
+
|
|
96
|
+
ax.perform(item, "AXPick") # menu items take AXPick, not AXPress
|
|
97
|
+
time.sleep(3.0)
|
|
98
|
+
dialog = _dialog(ax_app)
|
|
99
|
+
if dialog is None:
|
|
100
|
+
raise DialogError("the studies dialog did not open")
|
|
101
|
+
return dialog
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def applied(ax_app=None) -> list[str]:
|
|
105
|
+
"""Studies currently on the chart."""
|
|
106
|
+
dialog = _dialog(ax_app or ax.handle())
|
|
107
|
+
if dialog is None:
|
|
108
|
+
return []
|
|
109
|
+
names = []
|
|
110
|
+
for element in ax.find(dialog, role="AXStaticText"):
|
|
111
|
+
value = ax.attr(element, "AXValue")
|
|
112
|
+
if isinstance(value, str) and "(" in value and value.strip():
|
|
113
|
+
names.append(value.strip()[:46])
|
|
114
|
+
return list(dict.fromkeys(names)) # the dialog renders each row twice
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _row_label(row) -> str:
|
|
118
|
+
found = []
|
|
119
|
+
ax.walk(row, lambda el: found.append(
|
|
120
|
+
ax.attr(el, "AXValue") or ax.attr(el, "AXTitle") or ax.attr(el, "AXDescription")))
|
|
121
|
+
return next((str(v).strip() for v in found if isinstance(v, str) and v.strip()), "")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def add(search: str, exact: str | None = None, app=None) -> bool:
|
|
125
|
+
"""Filter the catalogue and add one study.
|
|
126
|
+
|
|
127
|
+
``exact`` matters: the list is alphabetical, so searching "vwap" offers
|
|
128
|
+
AnchoredVWAP first and a naive "take the first row" picks the wrong one.
|
|
129
|
+
"""
|
|
130
|
+
app = app or ax.running_app()
|
|
131
|
+
pid = app.processIdentifier()
|
|
132
|
+
ax_app = ax.handle(app)
|
|
133
|
+
dialog = _dialog(ax_app)
|
|
134
|
+
if dialog is None:
|
|
135
|
+
raise DialogError("the studies dialog is not open")
|
|
136
|
+
want = (exact or search).lower()
|
|
137
|
+
|
|
138
|
+
# The filter field is the text field inside a combo box - the same
|
|
139
|
+
# distinctive shape as the chart's symbol entry. Picking "the first wide
|
|
140
|
+
# text field" grabs something else and the catalogue never filters,
|
|
141
|
+
# leaving the category tree ("All Studies", "Alpha Studies") in view.
|
|
142
|
+
def _is_filter(element):
|
|
143
|
+
parent = ax.attr(element, "AXParent")
|
|
144
|
+
return bool(parent is not None and ax.attr(parent, "AXRole") == "AXComboBox")
|
|
145
|
+
|
|
146
|
+
fields = ax.find(dialog, role="AXTextField", predicate=_is_filter)
|
|
147
|
+
if not fields:
|
|
148
|
+
raise DialogError("the study filter field was not found")
|
|
149
|
+
ax.set_attr(fields[0], "AXFocused", True)
|
|
150
|
+
time.sleep(0.3)
|
|
151
|
+
ax.key(ax.KEYCODE["a"], 1 << 20, pid) # command-A
|
|
152
|
+
time.sleep(0.1)
|
|
153
|
+
ax.type_text(search, pid)
|
|
154
|
+
time.sleep(1.2)
|
|
155
|
+
|
|
156
|
+
rows = ax.find(dialog, role="AXRow",
|
|
157
|
+
predicate=lambda el: (ax.position(el) or type("", (), {"x": 9e9})).x < 900)
|
|
158
|
+
target = next((r for r in rows if _row_label(r).lower() == want), None)
|
|
159
|
+
if target is None:
|
|
160
|
+
raise DialogError(f"no study named {want!r}; offered: "
|
|
161
|
+
f"{[_row_label(r) for r in rows][:5]}")
|
|
162
|
+
button = _button(dialog, "Add selected")
|
|
163
|
+
if button is None:
|
|
164
|
+
raise DialogError("'Add selected' not found")
|
|
165
|
+
|
|
166
|
+
# Rows answer to neither AXPress nor the keyboard: only a real click
|
|
167
|
+
# selects one, and until one is selected the button stays greyed.
|
|
168
|
+
spot, target_spot = ax.centre(button), ax.centre(target)
|
|
169
|
+
blockers = ax.occluders_at(*target_spot, pid) + ax.occluders_at(*spot, pid)
|
|
170
|
+
hidden = ax.hide_apps(list(dict.fromkeys(blockers)))
|
|
171
|
+
try:
|
|
172
|
+
ax.click(*target_spot, pid)
|
|
173
|
+
time.sleep(0.6)
|
|
174
|
+
ax.click(*spot, pid)
|
|
175
|
+
time.sleep(1.4)
|
|
176
|
+
finally:
|
|
177
|
+
ax.unhide_apps(hidden)
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def press(caption: str, app=None) -> bool:
|
|
182
|
+
"""Press a labelled button in the dialog."""
|
|
183
|
+
app = app or ax.running_app()
|
|
184
|
+
dialog = _dialog(ax.handle(app))
|
|
185
|
+
button = _button(dialog, caption) if dialog else None
|
|
186
|
+
if button is None:
|
|
187
|
+
return False
|
|
188
|
+
return ax.perform(button, "AXPress")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def setup(row_height: str | None = None, per: str = "CHART", app=None) -> list[str]:
|
|
192
|
+
"""Rebuild the study set from scratch. Idempotent.
|
|
193
|
+
|
|
194
|
+
``row_height`` should match the underlying's strike increment so profile
|
|
195
|
+
buckets land on tradable strikes.
|
|
196
|
+
"""
|
|
197
|
+
app = app or ax.running_app()
|
|
198
|
+
cfg = config.load()
|
|
199
|
+
height = row_height or cfg["row_height"]
|
|
200
|
+
open_dialog(app)
|
|
201
|
+
press("Remove all", app)
|
|
202
|
+
time.sleep(0.8)
|
|
203
|
+
for search, exact in PRESET:
|
|
204
|
+
add(search, exact, app)
|
|
205
|
+
configure_volume_profile(height, per, app)
|
|
206
|
+
# Read the result BEFORE closing: applied() inspects the dialog, which
|
|
207
|
+
# no longer exists once OK is pressed.
|
|
208
|
+
result = applied()
|
|
209
|
+
press("OK", app)
|
|
210
|
+
time.sleep(2.5)
|
|
211
|
+
config.update(row_height=height)
|
|
212
|
+
return result
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def configure_volume_profile(row_height: str = "1.0", per: str = "CHART", app=None) -> bool:
|
|
216
|
+
"""Set the profile's row height and period in its customiser."""
|
|
217
|
+
app = app or ax.running_app()
|
|
218
|
+
ax_app = ax.handle(app)
|
|
219
|
+
dialog = _dialog(ax_app)
|
|
220
|
+
if dialog is None:
|
|
221
|
+
raise DialogError("the studies dialog is not open")
|
|
222
|
+
|
|
223
|
+
rows = [el for el in ax.find(dialog, role="AXStaticText")
|
|
224
|
+
if str(ax.attr(el, "AXValue") or "").startswith("VolumeProfile")]
|
|
225
|
+
if not rows:
|
|
226
|
+
raise DialogError("VolumeProfile is not in the applied list")
|
|
227
|
+
# Find the row's settings control structurally: the buttons sharing its
|
|
228
|
+
# vertical band, rightmost last. A fixed pixel offset from the label
|
|
229
|
+
# would break the moment the dialog is resized or relaid out.
|
|
230
|
+
row_spot = ax.centre(rows[0])
|
|
231
|
+
|
|
232
|
+
def _same_band(element):
|
|
233
|
+
p, s = ax.position(element), ax.size(element)
|
|
234
|
+
return bool(p and s and p.y <= row_spot[1] <= p.y + s.height
|
|
235
|
+
and p.x > row_spot[0])
|
|
236
|
+
|
|
237
|
+
controls = sorted(ax.find(dialog, role="AXButton", predicate=_same_band),
|
|
238
|
+
key=lambda el: ax.position(el).x)
|
|
239
|
+
if not controls:
|
|
240
|
+
raise DialogError("the VolumeProfile settings control was not found")
|
|
241
|
+
gear = controls[-1]
|
|
242
|
+
ax.perform(gear, "AXPress")
|
|
243
|
+
time.sleep(2.5)
|
|
244
|
+
|
|
245
|
+
sheet = next((w for w in ax.attr(ax_app, "AXWindows") or []
|
|
246
|
+
if (ax.attr(w, "AXTitle") or "").startswith("VolumeProfile")), None)
|
|
247
|
+
if sheet is None:
|
|
248
|
+
raise DialogError("the VolumeProfile customiser did not open")
|
|
249
|
+
|
|
250
|
+
combos = sorted(ax.find(sheet, role="AXComboBox"),
|
|
251
|
+
key=lambda el: (ax.position(el).y, ax.position(el).x))
|
|
252
|
+
if len(combos) < 3:
|
|
253
|
+
raise DialogError("unexpected customiser layout")
|
|
254
|
+
# Combos are not settable, but type-ahead works once focused.
|
|
255
|
+
for combo, letter in ((combos[0], "c"), (combos[2], per[0].lower())):
|
|
256
|
+
ax.set_attr(combo, "AXFocused", True)
|
|
257
|
+
time.sleep(0.35)
|
|
258
|
+
ax.key(ax.KEYCODE[letter], 0, app.processIdentifier())
|
|
259
|
+
time.sleep(0.7)
|
|
260
|
+
|
|
261
|
+
ok = _button(sheet, "OK")
|
|
262
|
+
if ok is not None:
|
|
263
|
+
ax.perform(ok, "AXPress")
|
|
264
|
+
time.sleep(2.0)
|
|
265
|
+
return True
|
chartremotely/symbol.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Drive the chart's symbol field.
|
|
2
|
+
|
|
3
|
+
``AXValue`` on that field is read-only, so the symbol cannot be written
|
|
4
|
+
directly. ``AXFocused`` *is* settable, so the field is focused and typed
|
|
5
|
+
into - which is also why the vocabulary can be kept narrow: this module can
|
|
6
|
+
only ever put text in one box.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import Quartz
|
|
15
|
+
|
|
16
|
+
from . import ax, config, layout
|
|
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]+)?$")
|
|
20
|
+
|
|
21
|
+
class NotFound(RuntimeError):
|
|
22
|
+
"""The symbol field could not be located or did not validate."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def to_ticker(text: str) -> str:
|
|
26
|
+
"""Normalise a ticker. Name resolution happens in :mod:`resolve`."""
|
|
27
|
+
t = re.sub(r"[^A-Z0-9./$-]", "", text.strip().upper())
|
|
28
|
+
# The SEC writes share classes as BRK-B; thinkorswim wants BRK/B.
|
|
29
|
+
return re.sub(r"-([A-Z]{1,2})$", r"/\1", t)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def validate(element) -> tuple[bool, str]:
|
|
33
|
+
"""True only for something that really is a symbol entry field.
|
|
34
|
+
|
|
35
|
+
This is the guard that keeps a stale offset from typing a ticker into
|
|
36
|
+
whatever else happens to be at those coordinates.
|
|
37
|
+
"""
|
|
38
|
+
if element is None or ax.attr(element, "AXRole") != "AXTextField":
|
|
39
|
+
return False, "not a text field"
|
|
40
|
+
parent = ax.attr(element, "AXParent")
|
|
41
|
+
if parent is None or ax.attr(parent, "AXRole") != "AXComboBox":
|
|
42
|
+
return False, "parent is not a combo box"
|
|
43
|
+
if not ax.settable(element, "AXFocused"):
|
|
44
|
+
return False, "field does not accept focus"
|
|
45
|
+
value = ax.attr(element, "AXValue")
|
|
46
|
+
if not isinstance(value, str) or not TICKER.match(value.strip()):
|
|
47
|
+
return False, f"current value {value!r} is not ticker-shaped"
|
|
48
|
+
return True, value.strip()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def discover(ax_app, window_prefix: str) -> tuple[int, int] | None:
|
|
52
|
+
"""Find the symbol field without assuming where the chart is.
|
|
53
|
+
|
|
54
|
+
Searches each pane the accessibility tree reports, relative to that
|
|
55
|
+
pane's own bounds. No screen coordinates are assumed, so a user whose
|
|
56
|
+
chart sits bottom-right - or who rearranges it at lunchtime - is found
|
|
57
|
+
the same way.
|
|
58
|
+
"""
|
|
59
|
+
return layout.find_in_panes(
|
|
60
|
+
ax_app, window_prefix, lambda el: validate(el)[0])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def discover_all(ax_app, window_prefix: str) -> list[tuple[int, int]]:
|
|
64
|
+
"""Every symbol field on screen, for layouts holding several charts."""
|
|
65
|
+
hits = []
|
|
66
|
+
for rect in layout.panes(ax_app, window_prefix):
|
|
67
|
+
hits += layout.scan_all(ax_app, rect, lambda el: validate(el)[0])
|
|
68
|
+
return hits
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def learn(app=None) -> dict:
|
|
72
|
+
"""Locate the field and record its window-relative offset."""
|
|
73
|
+
app = app or ax.running_app()
|
|
74
|
+
ax.activate(app)
|
|
75
|
+
ax_app = ax.handle(app)
|
|
76
|
+
cfg = config.load()
|
|
77
|
+
found = discover(ax_app, cfg["window_prefix"])
|
|
78
|
+
if found is None:
|
|
79
|
+
raise NotFound("no symbol field found; is a chart visible?")
|
|
80
|
+
win = ax.window(ax_app, cfg["window_prefix"])
|
|
81
|
+
if win is None:
|
|
82
|
+
raise NotFound(f"no window titled {cfg['window_prefix']!r}")
|
|
83
|
+
origin = ax.position(win)
|
|
84
|
+
return config.update(symbol_dx=int(found[0] - origin.x),
|
|
85
|
+
symbol_dy=int(found[1] - origin.y))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def locate(ax_app, cfg: dict):
|
|
89
|
+
"""Resolve the field through the window's CURRENT position.
|
|
90
|
+
|
|
91
|
+
The stored offset only proposes where to look; validation decides
|
|
92
|
+
whether to act. A small ring absorbs minor layout drift, and a full
|
|
93
|
+
rediscovery handles the rest.
|
|
94
|
+
"""
|
|
95
|
+
win = ax.window(ax_app, cfg["window_prefix"])
|
|
96
|
+
if win is None:
|
|
97
|
+
raise NotFound(f"no window titled {cfg['window_prefix']!r}")
|
|
98
|
+
origin = ax.position(win)
|
|
99
|
+
|
|
100
|
+
if cfg.get("symbol_dx") is not None:
|
|
101
|
+
x, y = int(origin.x) + cfg["symbol_dx"], int(origin.y) + cfg["symbol_dy"]
|
|
102
|
+
ok, info = validate(ax.element_at(ax_app, x, y))
|
|
103
|
+
if ok:
|
|
104
|
+
return ax.element_at(ax_app, x, y), info
|
|
105
|
+
for r in (4, 8, 12):
|
|
106
|
+
for dx, dy in ((r, 0), (-r, 0), (0, r), (0, -r), (r, r), (-r, -r)):
|
|
107
|
+
ok, info = validate(ax.element_at(ax_app, x + dx, y + dy))
|
|
108
|
+
if ok:
|
|
109
|
+
return ax.element_at(ax_app, x + dx, y + dy), info
|
|
110
|
+
|
|
111
|
+
found = discover(ax_app, cfg["window_prefix"])
|
|
112
|
+
if found is None:
|
|
113
|
+
raise NotFound("symbol field did not validate and could not be rediscovered")
|
|
114
|
+
config.update(symbol_dx=int(found[0] - origin.x),
|
|
115
|
+
symbol_dy=int(found[1] - origin.y))
|
|
116
|
+
element = ax.element_at(ax_app, *found)
|
|
117
|
+
return element, validate(element)[1]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def current(app=None) -> str:
|
|
121
|
+
"""What the field reports.
|
|
122
|
+
|
|
123
|
+
Note this can be STALE: thinkorswim does not push accessibility updates,
|
|
124
|
+
so after a change it may still report the previous symbol. Use it to
|
|
125
|
+
confirm the field is reachable, never to confirm a change took.
|
|
126
|
+
"""
|
|
127
|
+
app = app or ax.running_app()
|
|
128
|
+
ax.activate(app)
|
|
129
|
+
_, value = locate(ax.handle(app), config.load())
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def show(ticker: str, app=None) -> str:
|
|
134
|
+
"""Put a ticker in the chart. Returns the symbol that was displaced."""
|
|
135
|
+
ticker = to_ticker(ticker)
|
|
136
|
+
if not ticker or not TICKER.match(ticker):
|
|
137
|
+
raise ValueError(f"not a valid ticker: {ticker!r}")
|
|
138
|
+
|
|
139
|
+
app = app or ax.running_app()
|
|
140
|
+
pid = app.processIdentifier()
|
|
141
|
+
ax.activate(app)
|
|
142
|
+
field, previous = locate(ax.handle(app), config.load())
|
|
143
|
+
|
|
144
|
+
if not ax.set_attr(field, "AXFocused", True):
|
|
145
|
+
raise NotFound("could not focus the symbol field")
|
|
146
|
+
time.sleep(0.15)
|
|
147
|
+
|
|
148
|
+
ax.key(ax.KEYCODE["a"], Quartz.kCGEventFlagMaskCommand, pid)
|
|
149
|
+
time.sleep(0.10)
|
|
150
|
+
ax.type_text(ticker, pid)
|
|
151
|
+
|
|
152
|
+
# thinkorswim pops an autocomplete list as you type. A Return arriving
|
|
153
|
+
# before it renders is swallowed and the list stays open over the chart.
|
|
154
|
+
# Let it settle, accept it, then commit - the second Return is a no-op
|
|
155
|
+
# on an already-committed field.
|
|
156
|
+
time.sleep(0.35)
|
|
157
|
+
ax.key(ax.KEY_RETURN, 0, pid)
|
|
158
|
+
time.sleep(0.25)
|
|
159
|
+
ax.key(ax.KEY_RETURN, 0, pid)
|
|
160
|
+
return previous
|
chartremotely/tailnet.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""This Mac's place on the patron's tailnet.
|
|
2
|
+
|
|
3
|
+
The voice Shortcut reaches the agent at an HTTPS address that exists only
|
|
4
|
+
inside the patron's Tailscale network. ``tailscale serve`` gives the Mac that
|
|
5
|
+
address and forwards ``/chart`` to the agent's loopback listener.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
|
|
14
|
+
DOWNLOAD = "https://tailscale.com/download/mac"
|
|
15
|
+
APP_CLI = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TailnetError(RuntimeError):
|
|
19
|
+
"""Tailscale is missing, signed out, or refused."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cli() -> str:
|
|
23
|
+
found = shutil.which("tailscale")
|
|
24
|
+
if found:
|
|
25
|
+
return found
|
|
26
|
+
if shutil.which(APP_CLI) or subprocess.run(["test", "-x", APP_CLI], check=False).returncode == 0:
|
|
27
|
+
return APP_CLI
|
|
28
|
+
raise TailnetError(f"Tailscale is not installed. Install it from {DOWNLOAD}, sign in, then run setup again.")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def status() -> dict:
|
|
32
|
+
ran = subprocess.run([cli(), "status", "--json"], capture_output=True, text=True, check=False)
|
|
33
|
+
if ran.returncode != 0:
|
|
34
|
+
raise TailnetError("Tailscale is not running or not signed in. Open Tailscale and sign in.")
|
|
35
|
+
return json.loads(ran.stdout or "{}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def chart_url(state: dict) -> str:
|
|
39
|
+
"""The Shortcut's address: this Mac's tailnet name plus ``/chart``."""
|
|
40
|
+
name = ((state.get("Self") or {}).get("DNSName") or "").rstrip(".")
|
|
41
|
+
if not name:
|
|
42
|
+
raise TailnetError("Tailscale reports no name for this Mac. Is MagicDNS on?")
|
|
43
|
+
return f"https://{name}/chart"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def serve(port: int) -> None:
|
|
47
|
+
"""Forward ``/chart`` on the tailnet address to the agent. Persistent and tailnet-only."""
|
|
48
|
+
ran = subprocess.run([cli(), "serve", "--bg", "--set-path", "/chart", f"http://127.0.0.1:{port}"],
|
|
49
|
+
capture_output=True, text=True, check=False)
|
|
50
|
+
if ran.returncode != 0:
|
|
51
|
+
raise TailnetError(f"tailscale serve refused: {(ran.stderr or ran.stdout).strip()[:200]}")
|