mindvest-atlas 0.48.0 → 0.49.0
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.
- package/package.json +1 -1
- package/src/commands/computer-use.js +20 -2
- package/src/commands/historical.js +6 -1
- package/src/config.js +1 -1
- package/src/helpers/atlas_env.py +593 -74
- package/src/util/format.js +12 -0
package/package.json
CHANGED
|
@@ -92,11 +92,29 @@ const OPS = {
|
|
|
92
92
|
'mouse-up': ['mouse_up', 0],
|
|
93
93
|
'key-down': ['key_down', 1],
|
|
94
94
|
'key-up': ['key_up', 1],
|
|
95
|
+
// ── WINDOWS, BY NAME ──────────────────────────────────────────────────
|
|
96
|
+
// A window that is not on the screen is not gone, it is minimised, and a run
|
|
97
|
+
// with no way to say so hunted for it with the mouse and gave up (owner,
|
|
98
|
+
// 2026-09-06: "unable to navigate multiple pages or minimize etc"). A named
|
|
99
|
+
// command beats a key chord, which goes wherever the focus is and reports
|
|
100
|
+
// success either way.
|
|
101
|
+
windows: ['windows', 0],
|
|
102
|
+
minimize: ['minimize_window', 1],
|
|
103
|
+
maximize: ['maximize_window', 1],
|
|
104
|
+
restore: ['restore_window', 1],
|
|
105
|
+
'close-window': ['close_window', 1],
|
|
106
|
+
'move-window': ['move_window', 3],
|
|
107
|
+
'resize-window': ['resize_window', 3],
|
|
108
|
+
// ── A PAGE THAT IS TALLER THAN THE SCREEN ─────────────────────────────
|
|
109
|
+
'scroll-to': ['scroll_to', 1],
|
|
110
|
+
'scan-page': ['scan_page', 0],
|
|
95
111
|
};
|
|
96
112
|
// Which values are numbers. Passing "840" as a string to click() would move the
|
|
97
|
-
// pointer nowhere and report success.
|
|
113
|
+
// pointer nowhere and report success. THE SAME SET THE PYTHON CLI USES: a verb
|
|
114
|
+
// that takes numbers in one runtime and strings in the other is a verb nobody
|
|
115
|
+
// can rely on.
|
|
98
116
|
const NUMERIC = new Set(['use-screen', 'click', 'double-click', 'right-click',
|
|
99
|
-
'middle-click', 'triple-click', 'move', 'drag', 'scroll']);
|
|
117
|
+
'middle-click', 'triple-click', 'move', 'drag', 'scroll', 'wait', 'region']);
|
|
100
118
|
|
|
101
119
|
/** This shell's holding id, made once and remembered. Shared with `atlas run`
|
|
102
120
|
* so everything typed at one prompt lands in ONE session: the log reads as a
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `atlas historical-contract --symbol NVDA --expiry 2026-07-13 --strike 205 --side call
|
|
2
2
|
// --from 2026-07-08 --to 2026-07-10`
|
|
3
3
|
//
|
|
4
|
-
// Day-by-day price
|
|
4
|
+
// Day-by-day price, greeks and volume for ONE option contract. This is a friendly front end over
|
|
5
5
|
// the `Historical-Contract-Greeks` tool: short flags (--expiry / --from / --to), a
|
|
6
6
|
// side shorthand (--call / --put), per-flag "you forgot this one" errors, and a table
|
|
7
7
|
// instead of raw JSON. The generic caller still works (`atlas historical-contract-greeks
|
|
@@ -51,12 +51,16 @@ export function printHistoricalHelp() {
|
|
|
51
51
|
println(c.bold('OPTIONAL'));
|
|
52
52
|
println(' --to, --end <D> Last day to report (default: same as --from)');
|
|
53
53
|
println(' --no-greeks Prices only — skip the greeks (faster)');
|
|
54
|
+
println(' --no-volume Skip the traded volume (faster on long ranges)');
|
|
54
55
|
println(' --json Raw JSON instead of the table');
|
|
55
56
|
println();
|
|
56
57
|
println(c.bold('NOTES'));
|
|
57
58
|
println(c.dim(' The daily price is the MID of the last bid/ask before the 4pm close.'));
|
|
58
59
|
println(c.dim(' Greeks are calculated, not reported: no data provider publishes greeks for'));
|
|
59
60
|
println(c.dim(' past dates, so implied volatility is solved from that day\'s closing mid.'));
|
|
61
|
+
println(c.dim(' Volume IS reported (the official daily figure, not a count off the tape),'));
|
|
62
|
+
println(c.dim(' but reading it costs extra requests, so a long range or a far-from-the-money'));
|
|
63
|
+
println(c.dim(' strike may come back without it, and will say so.'));
|
|
60
64
|
println(c.dim(' Open interest is not available for past dates at all.'));
|
|
61
65
|
println(c.dim(' One contract per call, up to 90 trading days.'));
|
|
62
66
|
println();
|
|
@@ -112,6 +116,7 @@ export function buildHistoricalBody(args) {
|
|
|
112
116
|
if (to) body.to_date = String(to);
|
|
113
117
|
// `--no-greeks` parses to flags.greeks === false (the --no- convention).
|
|
114
118
|
if (flags.greeks === false || flags['include-greeks'] === false) body.include_greeks = false;
|
|
119
|
+
if (flags.volume === false || flags['include-volume'] === false) body.include_volume = false;
|
|
115
120
|
return { body };
|
|
116
121
|
}
|
|
117
122
|
|
package/src/config.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
|
|
5
|
-
export const VERSION = '0.
|
|
5
|
+
export const VERSION = '0.49.0';
|
|
6
6
|
export const DEFAULT_BASE_URL = 'https://atlasmcp.finmanagerai.com';
|
|
7
7
|
export const DEFAULT_SCOPE = 'atlas broker';
|
|
8
8
|
export const CLIENT_NAME = 'Atlas CLI';
|
package/src/helpers/atlas_env.py
CHANGED
|
@@ -998,6 +998,183 @@ def screens():
|
|
|
998
998
|
return [{"index": 1, "x": 0, "y": 0, "width": int(w), "height": int(h), "primary": True}]
|
|
999
999
|
|
|
1000
1000
|
|
|
1001
|
+
# ── THE PICTURE AND THE POINTER HAVE TO USE THE SAME NUMBERS ────────────────
|
|
1002
|
+
#
|
|
1003
|
+
# A screenshot is taken in the display's REAL pixels. The pointer moves in the
|
|
1004
|
+
# coordinate space the OS hands to applications. On Windows those are the same
|
|
1005
|
+
# once the process declares itself DPI-aware, which _gui() does. On a Retina Mac
|
|
1006
|
+
# they are NOT: the picture is 2880 across and the pointer's desk is 1440, so a
|
|
1007
|
+
# position read honestly off the picture lands at twice its distance from the
|
|
1008
|
+
# corner and every single click misses (owner, 2026-09-06: "it misses the
|
|
1009
|
+
# click"). Nothing errors; the press just goes somewhere else.
|
|
1010
|
+
#
|
|
1011
|
+
# So the ratio is measured once and the picture is SAVED IN POINTER NUMBERS.
|
|
1012
|
+
# What you read off it is what you press. There is nothing to remember, nothing
|
|
1013
|
+
# to multiply, and no way to get it wrong.
|
|
1014
|
+
_SCALE = {"px": None}
|
|
1015
|
+
_MSS = {"local": None}
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def _capture_scale():
|
|
1019
|
+
"""Picture pixels per pointer step. 1 nearly everywhere, 2 on a Retina Mac."""
|
|
1020
|
+
if _SCALE["px"] is not None:
|
|
1021
|
+
return _SCALE["px"]
|
|
1022
|
+
got = 1.0
|
|
1023
|
+
try:
|
|
1024
|
+
mons = screens()
|
|
1025
|
+
prim = next((m for m in mons if m.get("primary")), mons[0] if mons else None)
|
|
1026
|
+
w, _h = _gui().size()
|
|
1027
|
+
if prim and w and prim.get("width"):
|
|
1028
|
+
r = float(prim["width"]) / float(w)
|
|
1029
|
+
# Only a real, clean ratio counts. Anything odd is left alone rather
|
|
1030
|
+
# than guessed at: a wrong scale is worse than no scale.
|
|
1031
|
+
if 1.4 < r < 4.5:
|
|
1032
|
+
got = round(r * 2) / 2.0
|
|
1033
|
+
except Exception:
|
|
1034
|
+
got = 1.0
|
|
1035
|
+
_SCALE["px"] = got
|
|
1036
|
+
return got
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _mss_session():
|
|
1040
|
+
"""One mss per thread, kept. Opening one per grab costs a connection to the
|
|
1041
|
+
display server every time, which is most of what a quick look costs."""
|
|
1042
|
+
import mss as _mss
|
|
1043
|
+
import threading as _th
|
|
1044
|
+
if _MSS["local"] is None:
|
|
1045
|
+
_MSS["local"] = _th.local()
|
|
1046
|
+
sct = getattr(_MSS["local"], "sct", None)
|
|
1047
|
+
if sct is None:
|
|
1048
|
+
sct = _mss.mss()
|
|
1049
|
+
_MSS["local"].sct = sct
|
|
1050
|
+
return sct
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _grab(m):
|
|
1054
|
+
"""One monitor, as (picture, raw bytes).
|
|
1055
|
+
|
|
1056
|
+
The raw bytes are for COMPARING: hashing what came off the display is a
|
|
1057
|
+
tenth of the cost of encoding a PNG to find out nothing had moved. None when
|
|
1058
|
+
this machine's capture cannot hand them over, which only means every look
|
|
1059
|
+
counts as a change.
|
|
1060
|
+
"""
|
|
1061
|
+
box = {"left": int(m["x"]), "top": int(m["y"]),
|
|
1062
|
+
"width": int(m["width"]), "height": int(m["height"])}
|
|
1063
|
+
try:
|
|
1064
|
+
from PIL import Image as _Im
|
|
1065
|
+
shot = _mss_session().grab(box)
|
|
1066
|
+
return _Im.frombytes("RGB", shot.size, shot.bgra, "raw", "BGRX"), bytes(shot.raw)
|
|
1067
|
+
except ImportError:
|
|
1068
|
+
pass
|
|
1069
|
+
# WITHOUT mss, STILL THE RIGHT SCREEN. pyautogui grabs a region of the
|
|
1070
|
+
# virtual desktop, which is all this ever needed; mss is only faster.
|
|
1071
|
+
try:
|
|
1072
|
+
img = _gui().screenshot(region=(box["left"], box["top"], box["width"], box["height"]))
|
|
1073
|
+
except Exception as err:
|
|
1074
|
+
# Some platforms cannot reach a monitor left of the origin, where x is
|
|
1075
|
+
# negative. Say so rather than handing back the primary and letting
|
|
1076
|
+
# every coordinate read off it be wrong.
|
|
1077
|
+
raise RuntimeError(
|
|
1078
|
+
"Could not photograph screen %s on this computer (%s). Install mss "
|
|
1079
|
+
"for multi-screen capture, or use_screen(%s) to work on the main one."
|
|
1080
|
+
% (m.get("index"), err, _primary_index(screens()))
|
|
1081
|
+
)
|
|
1082
|
+
try:
|
|
1083
|
+
return img, img.tobytes()
|
|
1084
|
+
except Exception:
|
|
1085
|
+
return img, None
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def _grid_step(w, h):
|
|
1089
|
+
big = max(w, h)
|
|
1090
|
+
return 100 if big <= 1400 else (200 if big <= 3000 else 400)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _draw_grid(img, ox, oy, unit=1.0):
|
|
1094
|
+
"""A ruler, in the numbers you would actually click.
|
|
1095
|
+
|
|
1096
|
+
Reading a position off a picture by eye is the one thing a model is poor at,
|
|
1097
|
+
and it is the whole job here. So the picture carries its own coordinates:
|
|
1098
|
+
a dotted line every so often and the DESKTOP number written against it, which
|
|
1099
|
+
already includes this screen's origin. Read the label, click the label. That
|
|
1100
|
+
removes the arithmetic that made every click on a second monitor land one
|
|
1101
|
+
screen to the left, and it turns "about a third of the way across" into a
|
|
1102
|
+
number.
|
|
1103
|
+
"""
|
|
1104
|
+
try:
|
|
1105
|
+
from PIL import ImageDraw
|
|
1106
|
+
except ImportError:
|
|
1107
|
+
return img
|
|
1108
|
+
try:
|
|
1109
|
+
w, h = img.size
|
|
1110
|
+
except Exception:
|
|
1111
|
+
return img
|
|
1112
|
+
# `unit` is picture pixels per DESKTOP step. The picture may be saved
|
|
1113
|
+
# smaller than the desk it shows, so the lines are placed at round DESKTOP
|
|
1114
|
+
# numbers and then converted, never the other way round: the label has to be
|
|
1115
|
+
# a number that can be clicked.
|
|
1116
|
+
unit = float(unit) or 1.0
|
|
1117
|
+
step = _grid_step(int(w / unit), int(h / unit))
|
|
1118
|
+
try:
|
|
1119
|
+
d = ImageDraw.Draw(img)
|
|
1120
|
+
except Exception:
|
|
1121
|
+
return img # not something that can be drawn on
|
|
1122
|
+
line = (255, 0, 170)
|
|
1123
|
+
try:
|
|
1124
|
+
desk = step
|
|
1125
|
+
while desk * unit < w:
|
|
1126
|
+
gx = int(round(desk * unit))
|
|
1127
|
+
d.point([(gx, gy) for gy in range(0, h, 7)], fill=line)
|
|
1128
|
+
label = str(desk + int(ox))
|
|
1129
|
+
d.rectangle([gx + 1, 1, gx + 7 + 6 * len(label), 13], fill=(17, 17, 20))
|
|
1130
|
+
d.text((gx + 4, 3), label, fill=(255, 210, 240))
|
|
1131
|
+
desk += step
|
|
1132
|
+
desk = step
|
|
1133
|
+
while desk * unit < h:
|
|
1134
|
+
gy = int(round(desk * unit))
|
|
1135
|
+
d.point([(gx, gy) for gx in range(0, w, 7)], fill=line)
|
|
1136
|
+
label = str(desk + int(oy))
|
|
1137
|
+
d.rectangle([1, gy + 1, 7 + 6 * len(label), gy + 13], fill=(17, 17, 20))
|
|
1138
|
+
d.text((4, gy + 3), label, fill=(255, 210, 240))
|
|
1139
|
+
desk += step
|
|
1140
|
+
except Exception:
|
|
1141
|
+
pass # a ruler is help, never a reason to fail
|
|
1142
|
+
return img
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def _shot_cap():
|
|
1146
|
+
"""The longest edge a saved picture may have. 0 turns the cap off.
|
|
1147
|
+
|
|
1148
|
+
A LOOK IS NOT FINISHED UNTIL IT HAS BEEN READ. A full 4K desktop is a slow
|
|
1149
|
+
encode and a slower upload, and the model reading it downscales to about
|
|
1150
|
+
this anyway, so the pixels past here are paid for twice and used never.
|
|
1151
|
+
|
|
1152
|
+
It costs nothing in aim, which is the part that would matter: the ruler is
|
|
1153
|
+
drawn in DESKTOP numbers after the resize, and a point half way between two
|
|
1154
|
+
labels is half way between them whatever size the picture is.
|
|
1155
|
+
"""
|
|
1156
|
+
try:
|
|
1157
|
+
return max(0, int(os.environ.get("ATLAS_COMPUTER_SHOT_MAX", "1600")))
|
|
1158
|
+
except ValueError:
|
|
1159
|
+
return 1600
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _grid_wanted():
|
|
1163
|
+
"""On unless somebody turned it off."""
|
|
1164
|
+
return str(os.environ.get("ATLAS_COMPUTER_GRID", "1")).strip().lower() not in ("0", "off", "false", "no")
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _save_shot(img, out):
|
|
1168
|
+
"""Written fast. This is a local file that a model opens once; spending a
|
|
1169
|
+
fifth of a second squeezing it smaller is a fifth of a second of the
|
|
1170
|
+
person's time, every look."""
|
|
1171
|
+
try:
|
|
1172
|
+
img.save(out, "PNG", compress_level=1)
|
|
1173
|
+
except Exception:
|
|
1174
|
+
img.save(out)
|
|
1175
|
+
return out
|
|
1176
|
+
|
|
1177
|
+
|
|
1001
1178
|
def _primary_index(mons):
|
|
1002
1179
|
for m in mons:
|
|
1003
1180
|
if m.get("primary"):
|
|
@@ -1113,6 +1290,23 @@ def _current_screen():
|
|
|
1113
1290
|
{"index": 1, "x": 0, "y": 0, "width": 0, "height": 0, "primary": True})
|
|
1114
1291
|
|
|
1115
1292
|
|
|
1293
|
+
def _pointer_box(m):
|
|
1294
|
+
"""A monitor's bounds in the numbers CLICKS use.
|
|
1295
|
+
|
|
1296
|
+
screens() measures in the display's real pixels, because that is what a
|
|
1297
|
+
grab needs. Everything a caller says is in pointer steps. On a Retina Mac
|
|
1298
|
+
those differ by two, so a bounds check done in the wrong one refuses points
|
|
1299
|
+
that are plainly on the screen and accepts points that are not.
|
|
1300
|
+
"""
|
|
1301
|
+
sc = _capture_scale()
|
|
1302
|
+
if sc <= 1.01:
|
|
1303
|
+
return m
|
|
1304
|
+
out = dict(m)
|
|
1305
|
+
for k in ("x", "y", "width", "height"):
|
|
1306
|
+
out[k] = int(round(float(m.get(k) or 0) / sc))
|
|
1307
|
+
return out
|
|
1308
|
+
|
|
1309
|
+
|
|
1116
1310
|
def _on_screen(x, y):
|
|
1117
1311
|
"""Refuse a point that is not on the screen this run is working on.
|
|
1118
1312
|
|
|
@@ -1121,7 +1315,7 @@ def _on_screen(x, y):
|
|
|
1121
1315
|
"""
|
|
1122
1316
|
if x is None or y is None:
|
|
1123
1317
|
return
|
|
1124
|
-
m = _current_screen()
|
|
1318
|
+
m = _pointer_box(_current_screen())
|
|
1125
1319
|
if not m.get("width"):
|
|
1126
1320
|
return
|
|
1127
1321
|
inside = (m["x"] <= int(x) < m["x"] + m["width"]) and (m["y"] <= int(y) < m["y"] + m["height"])
|
|
@@ -1155,80 +1349,107 @@ def _on_screen(x, y):
|
|
|
1155
1349
|
)
|
|
1156
1350
|
|
|
1157
1351
|
|
|
1158
|
-
def screen(path=None):
|
|
1352
|
+
def screen(path=None, grid=None):
|
|
1159
1353
|
"""A picture of THE SCREEN this run is working on, and its path.
|
|
1160
1354
|
|
|
1161
1355
|
ONE screen, never several joined together: see use_screen(). Which one it
|
|
1162
1356
|
was is written into the log, so a replay never leaves you wondering what you
|
|
1163
1357
|
are looking at.
|
|
1164
1358
|
|
|
1359
|
+
THE PICTURE IS IN THE NUMBERS YOU CLICK IN, and it says them out loud.
|
|
1360
|
+
|
|
1361
|
+
* It is saved in POINTER space, so a Retina Mac's 2880-wide grab is a
|
|
1362
|
+
1440-wide picture: what you measure is what you press, with nothing to
|
|
1363
|
+
halve (owner, 2026-09-06: "it misses the click").
|
|
1364
|
+
* A dotted ruler is drawn over it, labelled with DESKTOP coordinates -
|
|
1365
|
+
this screen's origin already added. Read the nearest labels, count the
|
|
1366
|
+
gap, click that number. On a second monitor that also ends the mistake
|
|
1367
|
+
of clicking a position measured inside the picture.
|
|
1368
|
+
|
|
1369
|
+
Pass grid=False for a clean picture when you want to look at the pixels
|
|
1370
|
+
themselves rather than aim at them.
|
|
1371
|
+
|
|
1165
1372
|
WHAT COMES BACK IS THE PATH, and on a multi-screen machine also where that
|
|
1166
1373
|
screen STARTS:
|
|
1167
1374
|
|
|
1168
|
-
{"path": "...png", "screen": 2, "x": 1920, "y": 0,
|
|
1169
|
-
"note": "
|
|
1170
|
-
|
|
1171
|
-
That matters and it is the easiest thing here to get wrong. The picture is
|
|
1172
|
-
an image whose top left is 0,0; the pointer lives on the whole desktop,
|
|
1173
|
-
where that same corner may be 1920,0. A position read straight off the
|
|
1174
|
-
picture and clicked lands one screen to the left, and on a two-monitor desk
|
|
1175
|
-
that is EVERY click. Add the origin.
|
|
1375
|
+
{"path": "...png", "screen": 2, "x": 1920, "y": 0, "grid": 200,
|
|
1376
|
+
"note": "the ruler is labelled in desktop coordinates - click what it says"}
|
|
1176
1377
|
|
|
1177
|
-
|
|
1378
|
+
IDENTICAL PICTURES ARE NOT RE-SAVED. If nothing on the screen moved since
|
|
1379
|
+
the last look, the previous file comes back rather than an encode of the
|
|
1380
|
+
same pixels: the compare is a hash of the raw grab and costs a tenth of what
|
|
1381
|
+
writing the PNG does.
|
|
1178
1382
|
|
|
1179
1383
|
Not dashboard.shot(): that one draws this project's DASHBOARD in a frame. Two
|
|
1180
1384
|
things called screenshot, one of them the whole desktop, is a mistake
|
|
1181
1385
|
somebody makes once with a real click.
|
|
1182
1386
|
"""
|
|
1183
1387
|
m = _current_screen()
|
|
1388
|
+
box = _pointer_box(m)
|
|
1389
|
+
want_grid = _grid_wanted() if grid is None else bool(grid)
|
|
1184
1390
|
|
|
1185
1391
|
def take():
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
# than no picture: everything measured on it is measured on the
|
|
1202
|
-
# wrong desk.
|
|
1203
|
-
#
|
|
1204
|
-
# pyautogui can grab a REGION of the virtual desktop, which is all
|
|
1205
|
-
# this ever needed. Nothing about it requires mss; mss is only
|
|
1206
|
-
# faster.
|
|
1392
|
+
import hashlib
|
|
1393
|
+
img, raw = _grab(m)
|
|
1394
|
+
digest = hashlib.md5(raw).hexdigest() if raw is not None else None
|
|
1395
|
+
seen = _last_look()
|
|
1396
|
+
# NOTHING MOVED, SO NOTHING IS WRITTEN. The expensive part of a look is
|
|
1397
|
+
# the PNG, not the grab, and re-encoding pixels somebody already has is
|
|
1398
|
+
# the commonest thing a run does while it waits for a page.
|
|
1399
|
+
if (digest and not path and seen.get("hash") == digest and seen.get("path")
|
|
1400
|
+
and os.path.exists(seen["path"])):
|
|
1401
|
+
_remember_look(digest, seen["path"], changed=False)
|
|
1402
|
+
return seen["path"]
|
|
1403
|
+
# POINTER SPACE FIRST, so one picture pixel is one clickable step...
|
|
1404
|
+
sc = _capture_scale()
|
|
1405
|
+
unit = 1.0
|
|
1406
|
+
if sc > 1.01:
|
|
1207
1407
|
try:
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1408
|
+
from PIL import Image as _Im
|
|
1409
|
+
w = max(1, int(round(img.width / sc)))
|
|
1410
|
+
h = max(1, int(round(img.height / sc)))
|
|
1411
|
+
whole = int(round(sc))
|
|
1412
|
+
img = (img.reduce(whole) if abs(sc - whole) < 0.01 and whole > 1
|
|
1413
|
+
else img.resize((w, h), _Im.BILINEAR))
|
|
1414
|
+
except Exception:
|
|
1415
|
+
pass
|
|
1416
|
+
# ...and then, only for the sake of what has to read it, no bigger than
|
|
1417
|
+
# it needs to be. The ruler is drawn after this and in desktop numbers,
|
|
1418
|
+
# so aiming is untouched.
|
|
1419
|
+
cap = _shot_cap()
|
|
1420
|
+
try:
|
|
1421
|
+
longest = max(img.width, img.height)
|
|
1422
|
+
if cap and longest > cap:
|
|
1423
|
+
from PIL import Image as _Im2
|
|
1424
|
+
unit = float(cap) / float(longest)
|
|
1425
|
+
img = img.resize((max(1, int(img.width * unit)),
|
|
1426
|
+
max(1, int(img.height * unit))), _Im2.BILINEAR)
|
|
1427
|
+
except Exception:
|
|
1428
|
+
unit = 1.0
|
|
1429
|
+
if want_grid:
|
|
1430
|
+
img = _draw_grid(img, box.get("x") or 0, box.get("y") or 0, unit)
|
|
1431
|
+
out = path or os.path.join(_session_dir(), "screen-%d.png" % int(__import__("time").time() * 1000))
|
|
1432
|
+
_save_shot(img, out)
|
|
1433
|
+
_remember_look(digest, out, changed=True)
|
|
1434
|
+
return out
|
|
1435
|
+
|
|
1221
1436
|
out = _do("screen", take, {"monitor": m["index"]})
|
|
1222
1437
|
# A BARE PATH ON A SINGLE SCREEN, because that is what it has always been and
|
|
1223
1438
|
# nothing about one monitor needs explaining. Where there is more than one,
|
|
1224
1439
|
# the offset travels WITH the picture rather than waiting to be asked for:
|
|
1225
1440
|
# the moment it is needed is the moment the picture is read.
|
|
1226
|
-
if not
|
|
1441
|
+
if not box.get("x") and not box.get("y"):
|
|
1442
|
+
if not want_grid:
|
|
1443
|
+
return out
|
|
1227
1444
|
return out
|
|
1228
|
-
return {"path": out, "screen": m["index"], "x":
|
|
1229
|
-
"width":
|
|
1230
|
-
"
|
|
1231
|
-
|
|
1445
|
+
return {"path": out, "screen": m["index"], "x": box["x"], "y": box["y"],
|
|
1446
|
+
"width": box["width"], "height": box["height"],
|
|
1447
|
+
"grid": _grid_step(box["width"], box["height"]) if want_grid else 0,
|
|
1448
|
+
"note": ("the ruler drawn on this picture is labelled in DESKTOP "
|
|
1449
|
+
"coordinates - click the numbers it shows, do not add "
|
|
1450
|
+
"anything to them") if want_grid else
|
|
1451
|
+
"add x %s, y %s to positions you read off this picture"
|
|
1452
|
+
% (box["x"], box["y"])}
|
|
1232
1453
|
|
|
1233
1454
|
|
|
1234
1455
|
# ── STAYING ALIVE BETWEEN COMMANDS ──────────────────────────────────────────
|
|
@@ -1361,14 +1582,46 @@ def base64_token():
|
|
|
1361
1582
|
return base64.urlsafe_b64encode(os.urandom(18)).decode("ascii").rstrip("=")
|
|
1362
1583
|
|
|
1363
1584
|
|
|
1585
|
+
def _look_note():
|
|
1586
|
+
return os.path.join(_session_dir(), "last-screen.json")
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def _last_look():
|
|
1590
|
+
"""The previous look: what the screen hashed to, and where that picture is.
|
|
1591
|
+
|
|
1592
|
+
On disk, because a shell run is a row of separate processes and each one has
|
|
1593
|
+
to know what the one before it saw.
|
|
1594
|
+
"""
|
|
1595
|
+
import json as _j
|
|
1596
|
+
try:
|
|
1597
|
+
with open(_look_note(), encoding="utf-8") as fh:
|
|
1598
|
+
got = _j.loads(fh.read())
|
|
1599
|
+
return got if isinstance(got, dict) else {}
|
|
1600
|
+
except Exception:
|
|
1601
|
+
return {}
|
|
1602
|
+
|
|
1603
|
+
|
|
1604
|
+
def _remember_look(digest, path, changed):
|
|
1605
|
+
import json as _j
|
|
1606
|
+
try:
|
|
1607
|
+
with open(_look_note(), "w", encoding="utf-8") as fh:
|
|
1608
|
+
fh.write(_j.dumps({"hash": digest, "path": path, "changed": bool(changed)}))
|
|
1609
|
+
except OSError:
|
|
1610
|
+
pass
|
|
1611
|
+
|
|
1612
|
+
|
|
1364
1613
|
def _screen_changed(path):
|
|
1365
|
-
"""True when
|
|
1614
|
+
"""True when the last picture differs from the one before it.
|
|
1366
1615
|
|
|
1367
|
-
Compared by
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1616
|
+
Compared by CONTENT, not by time, and compared on the raw grab before any
|
|
1617
|
+
encoding: screen() has already done that work and written down the verdict,
|
|
1618
|
+
so this is a read rather than a second hash of a file just written. Telling a
|
|
1619
|
+
reader "nothing moved" saves them opening an image to discover it.
|
|
1371
1620
|
"""
|
|
1621
|
+
seen = _last_look()
|
|
1622
|
+
if path and seen.get("path") == path and "changed" in seen:
|
|
1623
|
+
return bool(seen["changed"])
|
|
1624
|
+
# A picture taken by something other than screen(): fall back to hashing it.
|
|
1372
1625
|
import hashlib
|
|
1373
1626
|
if not path:
|
|
1374
1627
|
return True
|
|
@@ -1377,21 +1630,70 @@ def _screen_changed(path):
|
|
|
1377
1630
|
now = hashlib.md5(fh.read()).hexdigest()
|
|
1378
1631
|
except OSError:
|
|
1379
1632
|
return True
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
try:
|
|
1383
|
-
with open(mark, encoding="utf-8") as fh:
|
|
1384
|
-
was = fh.read().strip()
|
|
1385
|
-
except OSError:
|
|
1386
|
-
pass
|
|
1387
|
-
try:
|
|
1388
|
-
with open(mark, "w", encoding="utf-8") as fh:
|
|
1389
|
-
fh.write(now)
|
|
1390
|
-
except OSError:
|
|
1391
|
-
pass
|
|
1633
|
+
was = seen.get("hash")
|
|
1634
|
+
_remember_look(now, path, changed=was != now)
|
|
1392
1635
|
return was != now
|
|
1393
1636
|
|
|
1394
1637
|
|
|
1638
|
+
def _frame_digest(m=None):
|
|
1639
|
+
"""What the screen looks like RIGHT NOW, as a hash, with nothing written.
|
|
1640
|
+
|
|
1641
|
+
The cheap half of a look. Used to find out whether a page has finished
|
|
1642
|
+
moving, which is a question asked many times per run and never needs a file.
|
|
1643
|
+
"""
|
|
1644
|
+
import hashlib
|
|
1645
|
+
try:
|
|
1646
|
+
_img, raw = _grab(m or _current_screen())
|
|
1647
|
+
return hashlib.md5(raw).hexdigest()
|
|
1648
|
+
except Exception:
|
|
1649
|
+
return None
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def _settle(most_s, quiet=2, step=0.12, was_before=None):
|
|
1653
|
+
"""Wait for the screen to STOP MOVING, up to a limit. Returns what it waited.
|
|
1654
|
+
|
|
1655
|
+
A fixed pause is wrong in both directions: too long on a screen that was
|
|
1656
|
+
already still, too short on a page that is still painting. So this watches
|
|
1657
|
+
instead, and returns the moment two looks in a row agree. Most of the time
|
|
1658
|
+
that is a quarter of what a fixed wait would have cost, and on a slow page
|
|
1659
|
+
it is the one that is actually correct.
|
|
1660
|
+
"""
|
|
1661
|
+
import time as _time
|
|
1662
|
+
most_s = max(0.0, float(most_s))
|
|
1663
|
+
if most_s <= 0:
|
|
1664
|
+
return 0.0
|
|
1665
|
+
began = _time.time()
|
|
1666
|
+
same = 0
|
|
1667
|
+
was = _frame_digest()
|
|
1668
|
+
if was is None:
|
|
1669
|
+
_time.sleep(most_s) # nothing to watch with: the old behaviour
|
|
1670
|
+
return most_s
|
|
1671
|
+
# WAIT FOR IT TO START, NOT ONLY TO STOP. A click on a link is followed by a
|
|
1672
|
+
# moment where nothing has happened yet, and a screen that has not begun to
|
|
1673
|
+
# change is perfectly still: settling alone declares it done and hands back a
|
|
1674
|
+
# picture of the page you were already on, so the next turn is spent
|
|
1675
|
+
# discovering that and asking again. Half the allowance goes on waiting for
|
|
1676
|
+
# the first sign of movement; the rest on waiting for it to finish.
|
|
1677
|
+
if was_before is not None and was == was_before:
|
|
1678
|
+
while _time.time() - began < most_s / 2.0:
|
|
1679
|
+
_time.sleep(step)
|
|
1680
|
+
now = _frame_digest()
|
|
1681
|
+
if now is not None and now != was_before:
|
|
1682
|
+
was = now
|
|
1683
|
+
break
|
|
1684
|
+
while _time.time() - began < most_s:
|
|
1685
|
+
_time.sleep(step)
|
|
1686
|
+
now = _frame_digest()
|
|
1687
|
+
if now is not None and now == was:
|
|
1688
|
+
same += 1
|
|
1689
|
+
if same >= quiet:
|
|
1690
|
+
break
|
|
1691
|
+
else:
|
|
1692
|
+
same = 0
|
|
1693
|
+
was = now
|
|
1694
|
+
return _time.time() - began
|
|
1695
|
+
|
|
1696
|
+
|
|
1395
1697
|
def do(*steps, **kw):
|
|
1396
1698
|
"""Do several things, then look ONCE. The fast loop.
|
|
1397
1699
|
|
|
@@ -1415,11 +1717,19 @@ def do(*steps, **kw):
|
|
|
1415
1717
|
into something worth reading. Between the steps there is nothing to see that
|
|
1416
1718
|
you did not already know was coming.
|
|
1417
1719
|
|
|
1418
|
-
`settle`
|
|
1419
|
-
|
|
1720
|
+
`settle` is now a CEILING, not a pause. It waits for the screen to stop
|
|
1721
|
+
moving and gets on with it the moment it has, so a still screen costs a
|
|
1722
|
+
quarter of a second instead of six tenths and a page still painting gets the
|
|
1723
|
+
whole allowance. Raise it for something slow; `look=False` skips the picture
|
|
1724
|
+
entirely when the next thing you do does not depend on what happened.
|
|
1420
1725
|
"""
|
|
1421
|
-
settle = float(kw.get("settle",
|
|
1726
|
+
settle = float(kw.get("settle", 1.2))
|
|
1422
1727
|
look = kw.get("look", True)
|
|
1728
|
+
# What it looked like before any of this, so the wait afterwards can tell
|
|
1729
|
+
# "nothing has happened yet" from "it happened and is done". Only worth
|
|
1730
|
+
# measuring when there is going to be a wait: settle=0 says do not wait, and
|
|
1731
|
+
# a grab taken to inform a wait that will not happen is pure cost.
|
|
1732
|
+
before = _frame_digest() if look and steps and float(kw.get("settle", 1.2)) > 0 else None
|
|
1423
1733
|
did = []
|
|
1424
1734
|
for step in steps:
|
|
1425
1735
|
if not step:
|
|
@@ -1455,8 +1765,7 @@ def do(*steps, **kw):
|
|
|
1455
1765
|
if not look:
|
|
1456
1766
|
return {"did": did}
|
|
1457
1767
|
if settle > 0:
|
|
1458
|
-
|
|
1459
|
-
_time.sleep(settle)
|
|
1768
|
+
_settle(settle, was_before=before)
|
|
1460
1769
|
# THE WORK SUCCEEDING AND THE LOOK FAILING ARE TWO DIFFERENT THINGS.
|
|
1461
1770
|
#
|
|
1462
1771
|
# Every step ran, and then the picture at the end could not be taken, and the
|
|
@@ -1612,6 +1921,207 @@ def scroll(x=None, y=None, delta_x=0, delta_y=0):
|
|
|
1612
1921
|
return _do("scroll", go, {"x": x, "y": y, "delta_x": delta_x, "delta_y": delta_y})
|
|
1613
1922
|
|
|
1614
1923
|
|
|
1924
|
+
def _all_windows():
|
|
1925
|
+
g = _gui()
|
|
1926
|
+
try:
|
|
1927
|
+
got = list(g.getAllWindows())
|
|
1928
|
+
except Exception as err:
|
|
1929
|
+
raise RuntimeError(
|
|
1930
|
+
"This computer cannot list its windows from here (%s). On Windows "
|
|
1931
|
+
"that works out of the box; on macOS and Linux it usually does not, "
|
|
1932
|
+
"so click the window you want instead." % err
|
|
1933
|
+
)
|
|
1934
|
+
return [w for w in got if str(getattr(w, "title", "") or "").strip()]
|
|
1935
|
+
|
|
1936
|
+
|
|
1937
|
+
def _one_window(title):
|
|
1938
|
+
"""The window whose title contains `title`. Raises if there is no such thing.
|
|
1939
|
+
|
|
1940
|
+
NOT ATLAS, unless somebody asked for it. Minimising or closing the window
|
|
1941
|
+
that shows what this run is doing is the same self-inflicted wound as
|
|
1942
|
+
clicking Stop, and it is easier to do by accident because a title match is
|
|
1943
|
+
fuzzy.
|
|
1944
|
+
"""
|
|
1945
|
+
want = str(title or "").strip()
|
|
1946
|
+
if not want:
|
|
1947
|
+
raise RuntimeError("Say which window, by any part of its title. windows() lists them.")
|
|
1948
|
+
hit = [w for w in _all_windows() if want.lower() in str(getattr(w, "title", "")).lower()]
|
|
1949
|
+
if not hit:
|
|
1950
|
+
open_now = ", ".join(sorted({str(getattr(w, "title", ""))[:40] for w in _all_windows()})[:10])
|
|
1951
|
+
raise RuntimeError(
|
|
1952
|
+
'No window whose title contains "%s". Open right now: %s. windows() '
|
|
1953
|
+
"lists them all." % (want, open_now or "nothing this can see")
|
|
1954
|
+
)
|
|
1955
|
+
got = hit[0]
|
|
1956
|
+
if not _MAY_USE_ATLAS["ok"] and _is_atlas_title(str(getattr(got, "title", ""))):
|
|
1957
|
+
raise RuntimeError(
|
|
1958
|
+
'"%s" is Atlas Remote Control, which is showing the person what this '
|
|
1959
|
+
"run is doing. Touching its window is not something to decide on your "
|
|
1960
|
+
"own: if they asked for it, call use_atlas_app() first." % got.title
|
|
1961
|
+
)
|
|
1962
|
+
return got
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
def windows(title=""):
|
|
1966
|
+
"""Every window that is open, with where it is and what state it is in.
|
|
1967
|
+
|
|
1968
|
+
[{"title": "Edge", "x": 0, "y": 0, "width": 1920, "height": 1040,
|
|
1969
|
+
"minimized": False, "maximized": True, "active": True}, ...]
|
|
1970
|
+
|
|
1971
|
+
LOOK HERE BEFORE HUNTING WITH THE MOUSE. A window you cannot see is not gone,
|
|
1972
|
+
it is minimised, and the answer to that is one call rather than a search
|
|
1973
|
+
across a desktop for an icon.
|
|
1974
|
+
"""
|
|
1975
|
+
def go():
|
|
1976
|
+
out = []
|
|
1977
|
+
for w in _all_windows():
|
|
1978
|
+
t = str(getattr(w, "title", "") or "")
|
|
1979
|
+
if title and str(title).lower() not in t.lower():
|
|
1980
|
+
continue
|
|
1981
|
+
out.append({
|
|
1982
|
+
"title": t[:160],
|
|
1983
|
+
"x": int(getattr(w, "left", 0) or 0), "y": int(getattr(w, "top", 0) or 0),
|
|
1984
|
+
"width": int(getattr(w, "width", 0) or 0), "height": int(getattr(w, "height", 0) or 0),
|
|
1985
|
+
"minimized": bool(getattr(w, "isMinimized", False)),
|
|
1986
|
+
"maximized": bool(getattr(w, "isMaximized", False)),
|
|
1987
|
+
"active": bool(getattr(w, "isActive", False)),
|
|
1988
|
+
})
|
|
1989
|
+
return out
|
|
1990
|
+
return _do("windows", go, {"title": str(title)[:120]})
|
|
1991
|
+
|
|
1992
|
+
|
|
1993
|
+
def _window_action(what, title, verb):
|
|
1994
|
+
def go():
|
|
1995
|
+
w = _one_window(title)
|
|
1996
|
+
fn = getattr(w, verb, None)
|
|
1997
|
+
if not callable(fn):
|
|
1998
|
+
raise RuntimeError(
|
|
1999
|
+
"This computer cannot %s a window from here. Click its own "
|
|
2000
|
+
"button in the title bar instead." % what
|
|
2001
|
+
)
|
|
2002
|
+
fn()
|
|
2003
|
+
return True
|
|
2004
|
+
return _do(what, go, {"title": str(title)[:120]})
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
def minimize_window(title):
|
|
2008
|
+
"""Put a window out of the way, by name. Beats win+down, which goes wherever
|
|
2009
|
+
the focus is and reports success either way."""
|
|
2010
|
+
return _window_action("minimize_window", title, "minimize")
|
|
2011
|
+
|
|
2012
|
+
|
|
2013
|
+
def maximize_window(title):
|
|
2014
|
+
"""Fill the screen with one window. Do this before working in it: a maximised
|
|
2015
|
+
window is in a known place, so positions hold across a run."""
|
|
2016
|
+
return _window_action("maximize_window", title, "maximize")
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
def restore_window(title):
|
|
2020
|
+
"""Bring a minimised window back to the size it was."""
|
|
2021
|
+
return _window_action("restore_window", title, "restore")
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
def close_window(title):
|
|
2025
|
+
"""Close a window. It may ask about unsaved work, so look afterwards."""
|
|
2026
|
+
return _window_action("close_window", title, "close")
|
|
2027
|
+
|
|
2028
|
+
|
|
2029
|
+
def move_window(title, x, y):
|
|
2030
|
+
"""Put a window somewhere. Useful for getting two side by side."""
|
|
2031
|
+
def go():
|
|
2032
|
+
_one_window(title).moveTo(int(x), int(y))
|
|
2033
|
+
return True
|
|
2034
|
+
return _do("move_window", go, {"title": str(title)[:120], "x": x, "y": y})
|
|
2035
|
+
|
|
2036
|
+
|
|
2037
|
+
def resize_window(title, width, height):
|
|
2038
|
+
"""Make a window a size you chose, so what is in it is where you expect."""
|
|
2039
|
+
def go():
|
|
2040
|
+
_one_window(title).resizeTo(int(width), int(height))
|
|
2041
|
+
return True
|
|
2042
|
+
return _do("resize_window", go, {"title": str(title)[:120],
|
|
2043
|
+
"width": width, "height": height})
|
|
2044
|
+
|
|
2045
|
+
|
|
2046
|
+
# ── PAGES THAT ARE TALLER THAN THE SCREEN ───────────────────────────────────
|
|
2047
|
+
#
|
|
2048
|
+
# A picture shows what is VISIBLE. The rest of a long page is not missing, it is
|
|
2049
|
+
# below the fold, and a run that treats the picture as the whole thing concludes
|
|
2050
|
+
# the button is not there (owner, 2026-09-06: "unable to navigate multiple pages
|
|
2051
|
+
# ... scrolling down the page if it is cut off"). These two are the answer:
|
|
2052
|
+
# one scrolls to an end, the other reads the whole thing a screenful at a time.
|
|
2053
|
+
|
|
2054
|
+
|
|
2055
|
+
def scroll_to(where="bottom", x=None, y=None, most=40, amount=900):
|
|
2056
|
+
"""Scroll until the page stops moving. `where` is "bottom" or "top".
|
|
2057
|
+
|
|
2058
|
+
scroll_to("bottom") # the end of the list
|
|
2059
|
+
scroll_to("top", *middle) # back up before reading it again
|
|
2060
|
+
|
|
2061
|
+
Stops as soon as two scrolls in a row change nothing, so it costs what the
|
|
2062
|
+
page is long rather than a fixed number of turns, and it cannot spin for
|
|
2063
|
+
ever on something that scrolls infinitely: `most` is the ceiling.
|
|
2064
|
+
"""
|
|
2065
|
+
step = -abs(int(amount)) if str(where).lower().startswith("bot") else abs(int(amount))
|
|
2066
|
+
|
|
2067
|
+
def go():
|
|
2068
|
+
g = _gui()
|
|
2069
|
+
if x is not None and y is not None:
|
|
2070
|
+
g.moveTo(int(x), int(y))
|
|
2071
|
+
was = _frame_digest()
|
|
2072
|
+
moved = 0
|
|
2073
|
+
for _ in range(max(1, int(most))):
|
|
2074
|
+
g.scroll(step)
|
|
2075
|
+
import time as _time
|
|
2076
|
+
_time.sleep(0.12)
|
|
2077
|
+
now = _frame_digest()
|
|
2078
|
+
if now is not None and now == was:
|
|
2079
|
+
break
|
|
2080
|
+
was = now
|
|
2081
|
+
moved += 1
|
|
2082
|
+
return {"scrolled": moved, "reached_end": moved < int(most)}
|
|
2083
|
+
return _do("scroll_to", go, {"where": str(where), "x": x, "y": y})
|
|
2084
|
+
|
|
2085
|
+
|
|
2086
|
+
def scan_page(x=None, y=None, most=10, amount=None, grid=None):
|
|
2087
|
+
"""Read a page that does not fit: one picture per screenful, top to bottom.
|
|
2088
|
+
|
|
2089
|
+
pages = scan_page()
|
|
2090
|
+
# -> {"pictures": [...4 paths...], "screens": 4, "reached_end": True}
|
|
2091
|
+
|
|
2092
|
+
This is what to do when what you want is not in the picture. It scrolls by
|
|
2093
|
+
just under a screen each time so nothing falls between two of them, stops the
|
|
2094
|
+
moment a scroll changes nothing, and hands back every picture in order. One
|
|
2095
|
+
call, and you have the whole page instead of a guess about what is under it.
|
|
2096
|
+
|
|
2097
|
+
It does NOT return to the top afterwards: you are usually about to click
|
|
2098
|
+
something you just saw. scroll_to("top") when you want to be back.
|
|
2099
|
+
"""
|
|
2100
|
+
m = _pointer_box(_current_screen())
|
|
2101
|
+
step = int(amount) if amount else max(200, int((m.get("height") or 900) * 0.82))
|
|
2102
|
+
|
|
2103
|
+
def go():
|
|
2104
|
+
g = _gui()
|
|
2105
|
+
if x is not None and y is not None:
|
|
2106
|
+
g.moveTo(int(x), int(y))
|
|
2107
|
+
shots, was = [], None
|
|
2108
|
+
end = False
|
|
2109
|
+
import time as _time
|
|
2110
|
+
for _ in range(max(1, int(most))):
|
|
2111
|
+
got = screen(grid=grid)
|
|
2112
|
+
shots.append(got if isinstance(got, str) else got.get("path"))
|
|
2113
|
+
here = _frame_digest()
|
|
2114
|
+
if here is not None and here == was:
|
|
2115
|
+
end = True
|
|
2116
|
+
shots.pop() # the same screenful twice is not a page
|
|
2117
|
+
break
|
|
2118
|
+
was = here
|
|
2119
|
+
g.scroll(-abs(step))
|
|
2120
|
+
_time.sleep(0.18)
|
|
2121
|
+
return {"pictures": shots, "screens": len(shots), "reached_end": end}
|
|
2122
|
+
return _do("scan_page", go, {"x": x, "y": y, "step": step})
|
|
2123
|
+
|
|
2124
|
+
|
|
1615
2125
|
def wait(ms):
|
|
1616
2126
|
"""Wait, and say so. A pause nobody recorded looks like a hang in a replay.
|
|
1617
2127
|
|
|
@@ -1671,6 +2181,9 @@ _MAY_USE_ATLAS = {"ok": os.environ.get("ATLAS_MAY_DRIVE_APP", "") == "1"}
|
|
|
1671
2181
|
_ACTS_ON_A_WINDOW = (
|
|
1672
2182
|
"click", "double_click", "right_click", "middle_click", "triple_click",
|
|
1673
2183
|
"drag", "scroll", "type", "key", "key_down", "key_up", "mouse_down", "mouse_up",
|
|
2184
|
+
# These two turn the wheel many times over, so they are as much "acting in
|
|
2185
|
+
# whatever window is in front" as one scroll is.
|
|
2186
|
+
"scroll_to", "scan_page",
|
|
1674
2187
|
)
|
|
1675
2188
|
|
|
1676
2189
|
|
|
@@ -1726,9 +2239,15 @@ def _on_the_strip(x, y):
|
|
|
1726
2239
|
return False
|
|
1727
2240
|
|
|
1728
2241
|
|
|
2242
|
+
def _is_atlas_title(title):
|
|
2243
|
+
"""Is that Atlas's own window? Asked of a NAME rather than of the front
|
|
2244
|
+
window, because a window command names the one it acts on."""
|
|
2245
|
+
low = str(title or "").lower()
|
|
2246
|
+
return any(name in low for name in _ATLAS_WINDOWS) if low else False
|
|
2247
|
+
|
|
2248
|
+
|
|
1729
2249
|
def _in_the_atlas_window():
|
|
1730
|
-
|
|
1731
|
-
return any(name in front for name in _ATLAS_WINDOWS) if front else False
|
|
2250
|
+
return _is_atlas_title(_front_window())
|
|
1732
2251
|
|
|
1733
2252
|
#: How far the pointer can drift before it was somebody's hand.
|
|
1734
2253
|
#:
|
package/src/util/format.js
CHANGED
|
@@ -100,9 +100,13 @@ export function formatContractHistory(d) {
|
|
|
100
100
|
|
|
101
101
|
// Only show greek columns when the response actually carries them (--no-greeks).
|
|
102
102
|
const hasGreeks = days.some((r) => r.delta !== null && r.delta !== undefined);
|
|
103
|
+
// Same rule for volume: the column appears only if a day actually carries one,
|
|
104
|
+
// so an empty VOL column never implies the contract sat untraded.
|
|
105
|
+
const hasVolume = days.some((r) => r.volume !== null && r.volume !== undefined);
|
|
103
106
|
const head =
|
|
104
107
|
'DATE'.padEnd(12) + 'DTE'.padStart(4) + 'BID'.padStart(9) + 'ASK'.padStart(9) +
|
|
105
108
|
'MID'.padStart(9) + 'SPOT'.padStart(10) +
|
|
109
|
+
(hasVolume ? 'VOL'.padStart(10) : '') +
|
|
106
110
|
(hasGreeks ? 'IV'.padStart(8) + 'DELTA'.padStart(9) + 'GAMMA'.padStart(9) + 'THETA'.padStart(9) : '');
|
|
107
111
|
out.push(c.dim(head));
|
|
108
112
|
|
|
@@ -113,11 +117,16 @@ export function formatContractHistory(d) {
|
|
|
113
117
|
cell(r.bid, 2, 9) + cell(r.ask, 2, 9) +
|
|
114
118
|
c.bold(cell(r.mid, 2, 9).replace(/^(\s*)/, '$1')) +
|
|
115
119
|
cell(r.underlying_price, 2, 10);
|
|
120
|
+
if (hasVolume) {
|
|
121
|
+
row += (r.volume === null || r.volume === undefined
|
|
122
|
+
? '—' : r.volume.toLocaleString('en-US')).padStart(10);
|
|
123
|
+
}
|
|
116
124
|
if (hasGreeks) {
|
|
117
125
|
row += ivCell(r.iv, 8) + cell(r.delta, 3, 9) + cell(r.gamma, 3, 9) + cell(r.theta, 3, 9);
|
|
118
126
|
}
|
|
119
127
|
out.push(row);
|
|
120
128
|
if (r.greeks_error) out.push(c.dim(` ↳ no greeks: ${r.greeks_error}`));
|
|
129
|
+
if (r.volume_error) out.push(c.dim(` ↳ no volume: ${r.volume_error}`));
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
// Never let a missing day pass silently — a holiday and a never-quoted strike look
|
|
@@ -127,6 +136,9 @@ export function formatContractHistory(d) {
|
|
|
127
136
|
for (const s of d.skipped) out.push(c.dim(`skipped ${s.date}: ${s.reason}`));
|
|
128
137
|
}
|
|
129
138
|
if (d.truncated) out.push(c.yellow(String(d.truncated)));
|
|
139
|
+
// Say it out loud: a table with no VOL column and no explanation reads as
|
|
140
|
+
// "this contract never traded".
|
|
141
|
+
if (d.volume_omitted) out.push(c.yellow(`volume not fetched: ${d.volume_omitted}`));
|
|
130
142
|
if (d.note) { out.push(''); out.push(c.dim(d.note)); }
|
|
131
143
|
return out;
|
|
132
144
|
}
|