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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "Atlas CLI \u2014 OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 + greeks for ONE option contract. This is a friendly front end over
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.48.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';
@@ -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": "add x 1920, y 0 to positions you read off this picture"}
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
- On a single screen the origin is 0,0 and this is just the path, as before.
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
- out = path or os.path.join(_session_dir(), "screen-%d.png" % int(__import__("time").time() * 1000))
1187
- try:
1188
- import mss as _mss
1189
- import mss.tools as _tools
1190
- with _mss.mss() as sct:
1191
- shot = sct.grab({"left": m["x"], "top": m["y"],
1192
- "width": m["width"], "height": m["height"]})
1193
- _tools.to_png(shot.rgb, shot.size, output=out)
1194
- return out
1195
- except ImportError:
1196
- # WITHOUT mss, STILL THE RIGHT SCREEN. This used to photograph the
1197
- # whole primary display whatever use_screen had said, and then the
1198
- # log recorded "Looked at screen 2" beside a picture of screen 1
1199
- # (owner, 2026-09-06: "the screener and screenshot not taking the
1200
- # right"). A picture that lies about which screen it is of is worse
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
- shot = _gui().screenshot(region=(int(m["x"]), int(m["y"]),
1209
- int(m["width"]), int(m["height"])))
1210
- except Exception as err:
1211
- # Some platforms cannot reach a monitor left of the origin, where
1212
- # x is negative. Say so rather than handing back the primary and
1213
- # letting every coordinate read off it be wrong.
1214
- raise RuntimeError(
1215
- "Could not photograph screen %s on this computer (%s). Install "
1216
- "mss for multi-screen capture, or use_screen(%s) to work on "
1217
- "the main one." % (m["index"], err, _primary_index(screens()))
1218
- )
1219
- shot.save(out)
1220
- return out
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 m.get("x") and not m.get("y"):
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": m["x"], "y": m["y"],
1229
- "width": m["width"], "height": m["height"],
1230
- "note": "add x %s, y %s to positions you read off this picture"
1231
- % (m["x"], m["y"])}
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 this picture differs from the one before it.
1614
+ """True when the last picture differs from the one before it.
1366
1615
 
1367
- Compared by content, not by time: a screen that is still is genuinely
1368
- unchanged, and telling a reader so saves it opening an image to discover
1369
- nothing moved. Kept in the session folder, so it works across the separate
1370
- processes a shell run is made of.
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
- mark = os.path.join(_session_dir(), "last-screen.txt")
1381
- was = None
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` waits before the picture, for a page that is still moving. Set
1419
- `look=False` when the next thing you do does not depend on what happened.
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", 0.6))
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
- import time as _time
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
- front = _front_window().lower()
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
  #:
@@ -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
  }