Market close: a Vest entry now needs its symbol's market to stay open
for RESET_CLEAR_HOLDS holds of the leg (clearsClose), the clearance the
8 PM ET reset already gets: at the target hold, and at the leg's sized
hold when a leverage cap stretches it. That covers the weekly close and
the daily maintenance hour as Vest's market-hours periods list them.
Before a closure that runs past an 8 PM ET reset (the weekend, a
holiday: longClosure), the bot's own legs still open in the last 5
minutes are closed at market, loudly; a position the bot didn't open is
named in the log and left alone.
Vest's day: Vest accounts are planned, and their legs and entries
counted, on Vest's day (20:00 ET) instead of the Central date
(tradeDateFor, PlatformClient.tradeDate). Between 5 PM CT and the reset
the Central date counted the open Vest day as a prior day and as today's
realized P&L both ($3,000 to go, $800 made: $2,050 left, not $1,900) and
started the leg count over. Other platforms keep the Central date.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TopstepX and Tradovate report fills, so a trade is built from them, flat
to flat (lib/fill-trades.ts), off the window each sync already reads:
TopstepX's two days of Trade/search, Tradovate's sync snapshot. Neither
says who placed an order, so every order the bot places is noted in
own_orders, and a trade is the bot's when its opening fill's order is
one; its stop or take-profit closing it reads as such. A contract is
written only when the position before its window comes out the same
two passes running, so a fill landing between the position and history
reads can't build the wrong trades.
DXtrade reports positions: its record groups the last two days' filled
orders by position code, with each close's SETTLEMENT and COMMISSION.
The bot's own orders carry af- codes, so nothing is kept locally. Only
accounts with something going on are read, at most every 5 minutes.
The Trades button shows on every firm with a login, and the account
page lists any account's trades.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vest's position history gives an open position a placeholder close price
and a $0 P&L (290.25 on an NDX short at 29,538.96). Only a closed
position keeps them now — in the parser, in the stored row (a placeholder
already stored is cleared on the next read) and in the API.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An account that leaves Vest's active list (breached, closed) was never
read again, so a position it had open stayed open in the trades table.
When the list shrinks, and on every full history pass, the client reads
such an account's position history once more per run — its id from
memory or, after a restart, from Vest's closed-account history
(GET /v3/capital/accounts/history) — falling back to the login's token
if the closed account gets none of its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Vest's position history (GET /v3/positions) gives each position's size,
open and close price, P&L, fees, funding and why it closed. The client
reads it hourly and a minute after a position closes (or a flat balance
moves) into a new trades table, and stores open positions as they
appear. Vest can't tell an API order from a click, so the bot marks its
own opens and closes as it sends them; an unmarked trade since marking
began was someone else's.
The dashboard's Vest firm row gets a Trades button listing every trade
on its accounts with who opened it and how it closed; the account page
shows its own.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the master changes an account type with "New accounts only", it now
serves the type's old rules as kept rule sets (firm.keptAccountRules), each
for accounts dated inside [openedAt, closedAt). This build applies them:
- getAccountConfig, the one rules lookup trading reads, puts a kept set's
rule fields over the live type when the account's rules date falls in
its window. Id, label, prefix, size and the projection fields stay the
live type's, so every report still names the live type.
- account_assignments gains rules_date and rules_date_manual. A new row is
dated when the machine first sees the account, on the master's clock
when this one runs behind and never before the catalog's last change.
Auto dates come down to the broker's evidence: the end of the first fund
day in Chicago, or a DXtrade registration instant. Evidence is applied
before an account's first trade. Rows from before the migration get its
time; unpinning keeps the row and its date. Backups carry both columns.
- An account on a kept set stays on its type while its prefix and cached
size still match, so a tie-break flip can't move it off its old rules.
- Kept entries parse through the same normalizer as live types (now
normalizeAccountType in lib/kept-rules.ts); a malformed one rejects the
sync like a malformed type. The cached catalog records the parser that
stored it, and a cache from an older parser is fetched in full rather
than trusted with ?since= (this also brings in maxConcurrentAccounts).
- Reports add keptRuleIds per firm and live counts per kept set per type.
- The dashboard row and the account page show the rules date next to the
account name, with an "old rules" chip saying what differs. Clicking
the date edits it inline (PUT /api/accounts/:id/rules-date), previewing
which rules the date lands on.
Also: the master's per-firm cap on accounts traded at once
(maxConcurrentAccounts), applied on top of the machine's own max
concurrent when a batch is drawn and in copy trading.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`next build` imports lib/db.ts from several workers at once. On a fresh
machine they all saw an empty instruments table and raced to insert the
same symbols, failing the build with "UNIQUE constraint failed:
instruments.symbol". The emptiness check and the inserts now share one
IMMEDIATE transaction, and the insert is OR IGNORE as a backstop.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A sync reads the balance first and each account's positions and trades
after it, so a fill landing in between shows in the trades but not yet in
the balance, and for that one pass the books are off by the whole fill.
reconcileFunds booked that as a fund event, and the next pass booked the
reversal. Same-day rows merge, so the two netted to the commission drift
of the day and left a tiny row:
- 2026-10-05, Express 43788281 and 23275136: +3,001.16 "deposit" (balance
-2.16 against the -3,001.16 stop-out already in the trades), then
-3,002.16, merged to -1.00.
- 2026-10-08, combine 99745612: +2,474.01, then -2,492.01, left -18.00.
- 2026-09-24, five 150K combines on another instance: +3,049.45, then
-3,059.45, left -10.00 each.
A negative row is a withdrawal. It started a new cycle, dropped its day
out of the cycle (the -3,003.32 on 10-05) and counted toward the stage:
both Express accounts traded drain days from 10-06 on a $1 "payout".
- fundReconcileStep (trading-logic): an unexplained balance is booked
only when a check at least 30s after the first sighting still sees it,
to within $5. A transient is gone by the next pass and books nothing.
- A transient that outlasts that, and so books, is taken back whole once
its reversal nets the day under FUND_EVENT_MIN. The day's first booking
saves what it replaced (fund_day_before), and dropFundTransaction
deletes the row and puts last_fund_date and ended_cycle_start back in
one transaction. A row reconcileFunds didn't book (an import) merges as
before.
- funds_reconciled is set only once a check settles, so a first-check
lump that waits on a second pass is still booked as the first check.
Existing tiny rows are left as they are; cleaning them up is a separate,
one-time step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fifth review round (1eb0736):
- An entry now has three outcomes. Filled (confirmed): one-and-done is done
for the day. Never placed: leg taken back, free to retry. Unknown (an ack
or fill that never came back): the account — any mode — is held until a
good sync at least 90s later shows it flat with realized P&L unchanged,
then the leg is taken back and it may retry. Stop-after-all counts it in
play meanwhile. This closes both the day-long bench of 52ed9c7 and the
stacked re-entries of 1eb0736.
- Tradovate cancels an acked order whose fill didn't come within 30s (as
TopstepX already does), so a retry can't sit beside a still-working one.
- A wipe keeps the rebuild's guards: wipeAccountData leaves the Cash History
baseline, roll base, wipe hold, pre-wipe fund signature and the
resolver's inferred size in account_meta; the routes forget history
before wiping (so the stored rows are still there to read), and a wipe
hold restored after a restart keeps its pre-wipe yardstick.
- Wipe routes take the set of typed accounts before wiping, so an account
the resolver un-assigns mid-rebuild still counts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a fund transaction starts a new cycle (the days revert to 0), the
cycle it ended is checked against the completion rule for a week after and
named if it stood complete — its final day may never have been reported.
Only for a cycle this instance saw begin: the clients now note where the
ended cycle started as they move last_fund_date forward. Completions carry
the cycle they belong to, and each firm reports its whole account list so
the master can tell erased accounts and spot new funded ones.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lib/db.ts switched to WAL at module load before setting busy_timeout.
next build imports it from several workers while the live server holds
the same file, and a journal-mode change needs the file to itself — so
the pragma failed instantly with SQLITE_BUSY and took the whole build
down ("Failed to collect page data"), blocking updates on any box whose
database was still in rollback mode. The timeout is now set first and
the WAL switch is wrapped: a held file keeps its current mode.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matches the master's tokenless-push change: pushBackup no longer waits
for master_backup_token — every instance with a uuid backs up on its
own, and the 5-minute tick force-pushes so a poisoned master copy
self-heals. The token now only gates pulling a backup back (restore),
so setup-linux.sh asks for it only when restoring and the Settings
help text says so.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four features:
- Backup: lib/backup-sync.ts pushes the full settings table and firm
credentials (the deliberate exception to "credentials never leave the
machine") to the master's bearer-gated /api/instances/backup — every
5 minutes when the content hash changed, within seconds of a settings
or credential edit, and on the new backup-now command. Enabled by the
master_backup_token setting.
- Restore: a dead VPS's replacement pulls the backup and adopts its
uuid/name, so roster, payouts and card links carry on. Pre-boot via
seed-settings.js --restore-from (setup-linux.sh prompts for it) or
the Settings page's restore picker (lib/backup-restore.ts). Stop the
old machine first; the master 409s a live-name rename.
- Proxy: the proxy_url setting routes BROKER traffic only — TopstepX
REST + SignalR hub, Tradovate REST + websocket, Yahoo quotes — via
lib/outbound-proxy.ts (axios agents, undici dispatcher, ws agent).
The master link, reporter, catalog and backup push stay direct, so a
dead proxy cannot take down the control channel. Restart to apply.
- Multi-instance: AUTOFIRMER_DB moves the SQLite file so one checkout
runs N instances; scripts/instances.json (per-device, gitignored)
drives start-all/update-check, and add-instance.sh / remove-instance.sh
manage the extra PM2 processes. Absent registry = single-instance
behavior, unchanged. Instance 1 keeps port 3000 for the clicker and
extension.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the single hardcoded signed-out check with a guards list on each
firm: any guard can trip mid-run via a URL substring (checked on every
step) or a selector probe (checked when a step fails). Each guard defines
recovery steps or a human-wait fallback, a per-run trigger cap, and a
resume policy — 'continue' retries the tripped step, 'restart' rewinds
the run to step 1.
Runner bumped to 0.20.0; the server still ships the legacy
signedOut/authSteps shape for stale runners, and a new runner synthesizes
a guard from a legacy payload.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TopstepX never reports the Maximum Loss Limit, but it's deterministic:
floor = min(0, eodBalanceHigh − trailingDrawdown), locking at the $0
starting balance. The EOD curve rebuilds from daily P&L + fund events,
with off-book fee drift spread across days proportionally to reported
fees (validated against a platform-reported MLL to ~$0.10). Stored in
autoLiqThresholds — negative, or +$0.01 once locked — so dead detection,
dashboards, and the ruin model's startThreshold all use the real room.
Guards move from '> 0' to '!== 0'; 0 still means no floor. canTrade=false
accounts are skipped. daily_pnl gains a fees column for the weighting.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- TopstepXClient (ProjectX Gateway): API-key auth, SignalR user hub,
Trade/search daily P&L, emulated OCO exits, books-invariant
withdrawal detection for $0-start accounts
- Platform-aware client pool, credentials (api_key column), and
credential form; $0-size account types match by prefix alone
- Drain stages: min-day ($5-$15 randomized) MNQ legs for the
configured days, then wait for the 50% withdrawal; stage pills
show 'Stage N – Drain'
- Account calendar projects the perfect path: future days book their
planned targets, simulated 50% withdrawals, payout marker
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cached daily P&L rows outlive what the Cash History report still
produces (stale pre-roll dates being the recent example), and nothing
deletes them any more. POST /api/accounts/[id]/wipe drops the account's
cached P&L, fund transactions and meta, clears the in-memory view, and
rebuilds from a fresh report — fetchDaysTraded now takes an optional
account filter so one account doesn't cost a report round-trip on every
other. The button confirms inline first: rows older than the report's
28-day window cannot be refetched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cash History rows are stamped with the Central calendar date, but trades
in the evening session (>= 5:00 PM CT) belong to the next trade date -
matching currentTradeDateCT and when Tradovate resets realizedPnL. The
mismatch made computeDayPlan count the same session twice (as a phantom
prior day and as today's realized PnL), inflating the day target into a
moving one: 40% consistency accounts showed dayTarget = base + 0.4 x
today's PnL instead of the anchored day-start target.
Roll those rows forward at ingestion, and make a successful Cash History
report authoritative for its 28-day window so rows cached under the old
calendar date are dropped rather than double-counted. An empty report
never deletes anything (it can't distinguish "no trades" from "no
data"), and a report still pending after polling now throws instead of
masquerading as an empty one.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The frame-aware step vocabulary only the 0.19.0 runner understands, so the
server must require it too. Missed in the previous commit, which left the
dashboard flagging the restarted 0.19.0 runner as out of date.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The welcome now carries a personId; it lands in the instance_id setting
and rides on every hello and HTTP report from then on. The master keys
the person by that uuid, so changing instance_name moves our history to
the new name instead of starting a stranger. Read-only in Settings on
purpose — editing it by hand would detach this machine from its person.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This box no longer decides what firms exist. It caches the master's catalog
and stores one thing of its own: the Tradovate login for each firm. Creating
firms locally is gone — app/add, POST /api/firms, the account-config routes
and the banned-symbol route with it.
The cache is the point, not an optimisation. The scheduler needs every
account's target and size on each 60s tick, so fetching them per tick would
stop trading with positions open the first time the master rebooted. It is
one JSON blob in settings, replaced only by a complete valid response, and a
response with zero firms is refused outright: a misconfigured FIRMS_DB serves
{"firms":[]} as a healthy 200, and mirroring that would idle the whole fleet
at once.
Accounts bind to an account type by prefix AND size, the size taken from the
opening funding transaction. That does not arrive with the first sync, so an
account sits Unassigned and the resolver tries again next sync rather than
guessing. A type picked by hand is never overwritten by an auto pass. This
replaces the longest-prefix helper that had been copy-pasted into five files,
which could not tell two types with the same prefix apart at all.
firm_credentials and account_assignments deliberately have no foreign key to
the cached catalog. foreign_keys is ON, so a cascade would erase the Tradovate
passwords the moment a firm was removed on the master — routine, not
exceptional. They survive it, and are re-keyed by name if the master's database
is ever rebuilt with fresh ids.
The legacy firms/account_configs/firm_banned_symbols tables are left on disk,
unreferenced. firms is the only table that ever held plaintext credentials and
dropping it would make a rollback unrecoverable.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dashboard-hosting stack from the previous commit is gone; it was answering
a question nobody asked. What was actually needed is Chrome's real update
channel, which an unpacked extension does not have and cannot be given.
So the extension is now signed into a CRX with an update manifest beside it,
served from the droplet, and installed on the trading machines by policy
(ExtensionInstallForcelist). From there Chrome installs it, updates it on its
own schedule, and puts it back if it goes missing. The manifest gained the
`key` that fixes the extension id, so an unpacked development load keeps the
same identity as the published build.
The self-update check now branches on how it was installed: policy installs ask
Chrome to check the update server now rather than waiting out its five-hour
cycle, and unpacked loads keep the reload-from-disk path. chrome.management
.getSelf() tells the two apart and needs no permission to do it.
The signing key lives at /opt/autofirmer-updates/key.pem on the droplet and
nowhere else. It is the extension's identity: a different key is a different
extension, and no browser would take it as an update.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Chrome has no update channel for an unpacked extension, so every pull left one
manual step: click reload on its card. The dashboard now publishes the version
its own checkout ships at /api/autobuyer/status, and the extension reloads
itself when it is behind — chrome.runtime.reload() re-reads the folder from
disk, which the update check has already pulled into. It only ever runs code
that is on disk, so a reload that changes nothing is reported and waited out
rather than repeated; a dashboard that is behind is ignored.
Also adds the deploy stack for running the dashboard on the droplet, next to
the autotrader one: it joins that nginx's network and renders a server block
into its conf.d rather than standing up a second ingress. AUTOFIRMER_DB moves
the SQLite file onto a volume, since the working directory in a container is
replaced on every deploy.
And waitFor gained `hidden`: an absent gate matched on DOM presence only, so a
modal that closes by going display:none could never satisfy it. The extension
grew a visibility probe for this — locate cannot answer it, because absent and
hidden are both errors there and a wait gate has to tell them apart.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two Tradeify automations clicked the 2captcha *extension's* solve button
and waited for it to go green. That needed a second extension installed and
signed in, put a real mouse click on a button whose markup is 2captcha's to
change, and gave a run no way to tell "still solving" from "not installed".
Replaced with one `solveCaptcha` step. No mouse: the extension reads the
widget's sitekey out of the page, the dashboard buys a token from 2captcha,
and the extension puts it in the response field and fires the page's own
callback.
captcha-hook.js is the part that earns its keep. A widget rendered
programmatically puts nothing in the DOM — no sitekey, and no callback. Most
pages only enable submit from that callback, so filling the hidden field alone
leaves the page looking unsolved. The hook wraps turnstile/grecaptcha/hcaptcha
`render` at document_start to hold on to what the page passed.
Solves go through 2captcha's residential proxy. Its API rejects host names in
proxyAddress, so na.proxy.2captcha.com is resolved to an IP first (cached);
the gateway was confirmed to work addressed that way.
Covers reCAPTCHA v2 (incl. invisible and Enterprise), hCaptcha and Turnstile.
NOT reCAPTCHA v3: a v3 page carries an anchor iframe with k=, so it is
detected as v2 and would be solved with the wrong task type. Everything else
unsupported fails loudly instead.
The API key and proxy credentials are hardcoded in the captcha route on
request. Unlike the sqlite database, that file is in the repo, so they travel
with it. TWOCAPTCHA_API_KEY / TWOCAPTCHA_PROXY and the settings rows still
override; neither has a field on the Settings page.
Runner VERSION and RUNNER_EXPECTED_VERSION both to 0.17.0 — they are a pair,
and a mismatch shows as a red "restart your runner" banner that blames the
wrong process.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes that ride together because they touch the same surface.
A control link to the master. lib/master-link.ts dials out to
<master>/ws/instance and holds it open, so the dashboard can trigger a trade or
queue an AutoBuyer run here. Commands call the same functions the local pages
call — runTrade, startScheduler, queueRun — so nothing reaches the broker by a
path the UI does not also use. Reports prefer the socket and fall back to the
existing POST, which keeps older masters working unchanged.
startMasterLink is idempotent, and that matters more than it looks: getClients()
is lazy and starts the link, so the first command re-enters it from inside a
command the live socket is waiting to ack. Tearing the socket down there loses
the ack and reports a trade that actually executed as a failure — which invites
pressing the button twice.
queueRun is lifted out of the automations route so the socket and HTTP share one
copy of the input clamping that decides how many times a purchase fires.
Page capture loses its switch and gains a scope. It now runs every tick, but
only against the active tab of the last-focused window that matches
host_permissions — so it follows the page in front of you and stops when you
switch away, where before it would fall back to any firm tab left open in the
background.
autobuyer_enabled is gone entirely. The AutoBuyer is ready whenever the runner
is; a run happens because someone asked for one. That does mean a queued run
executes as soon as clicker/runner.py claims it, with no second gate — stopping
the runner is now the way to make a machine idle.
Extension to 0.10.0; it needs a manual reload at chrome://extensions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Instances were started by hand and updated by hand, so they drifted behind
master silently. Now: PM2 supervises the dashboard and the clicker, a logon
task brings them up, and a 5-minute task pulls, rebuilds and restarts when
master moves.
Restarting on every push is only safe because the scheduler now survives it.
It was pure in-memory state (_global.__autoTrader), so any restart silently
stopped automated trading with the dashboard simply showing it as off. It now
mirrors running/action/symbol/stopAfterAll to the settings table, and
resumeSchedulerIfPersisted() picks it back up from the getClients() bootstrap.
No sync-wait was needed there: tick() already skips while a client reports
!syncComplete and while any account holds a position.
A failed build is never deployed — the build runs before anything restarts, so
a broken push leaves the previous build serving.
start-all and update-check both warm the app with a request afterwards. That is
load-bearing: getClients() is lazily bootstrapped, so until something makes an
HTTP request the Tradovate clients, the reporter and the resumed schedule never
start. That was already true of manual restarts.
Logic lives in Node so a macOS or Linux port only needs an equivalent of
install-autostart.ps1. Python deps are hash-guarded, so the common path is one
hash and one import with no network, and failure is non-fatal since only the
clicker needs them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fresh installs point at the master dashboard without manual configuration.
Seeded with INSERT OR IGNORE, so existing databases are untouched.
Note that reporter.ts requires both master_dashboard_url and instance_name
to be non-empty, so this alone does not start reporting — instance_name is
still seeded blank and set per machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The other 17 symbols still seed, so they remain listed and can be switched
on from the Instruments page — they just start disabled.
Affects fresh installs only: the seed block runs only when the instruments
table is empty, so existing databases keep their current selection.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
scrollToLoad walks a progressively-loading list to the bottom before the steps
that act on its items run. Stopping is two-part: no new matches appeared AND the
container was already pinned to the bottom — counting alone stops early on a slow
fetch. Hitting the scroll cap is reported rather than passed off as done, so a
later step never works quietly on a partial list.
The scrolling element is usually not the window. Lists like this live in a div
with its own overflow, and scrolling the document does nothing at all, so the
step walks up from a matched item to the ancestor that actually scrolls —
overflow allows it and there is more content than fits — with containerSelector
to name one outright when the guess is wrong. Verified against a page whose
document also scrolls, which is the case that tells the two apart: it found the
inner div and pulled 12 items up to 60 in 7 scrolls.
waitFor gains `absent`, for waiting on something to go rather than arrive — a
modal closing after a reset. It only accepts a genuine "selector matched
nothing"; an unreachable extension looks the same from a distance and would
otherwise satisfy the gate for the wrong reason, sending the next iteration into
a page that still has the modal open.
The locate queue carries a free-form options blob now, so a new kind of request
stops meaning a new column each time.
Also fixes a missing comma in the reset flow that broke the build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
focus.py raised the first browser in its list that happened to be running. On a
machine with both Chrome and Edge installed that is a coin flip, and losing it is
silent: coordinates measured from a tab in one browser, the click delivered into
a window of the other. It presents as selectors failing for no reason. A VPS with
both installed hit exactly this.
The extension now reports which browser is hosting it, and that travels with the
measurement, so the clicker raises the browser the coordinates actually came
from. Asking for a browser that is not running now fails honestly instead of
quietly raising a different one, and the verification step rejects the wrong
browser coming forward. Chromium, Opera and Vivaldi are recognised alongside
Chrome, Edge and Brave, on both platforms.
diagnose.py answers the question a remote desktop makes hard: whether the mouse
is really moving or the viewer simply is not drawing it. It moves the cursor and
reads the position back from the OS, so the answer does not depend on anything
being rendered, and it reports DPI mode, screen size, whether this is an RDP
session, and whether the browser can be raised at all. Nothing is clicked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Next's dev server intermittently answers a 500 while recompiling a route: it
reads a build manifest mid-write and cannot parse it. A single one of those
during the locate poll was fatal, so a blip in the pipeline killed a run partway
through an auth flow on Windows.
5xx responses and dropped connections are now a distinct TransientError, retried
until the step's own timeout. A 4xx still fails immediately — those are verdicts
about the request, not blips. If the errors persist all the way to the timeout,
the message says so rather than blaming a missing extension.
Error bodies are also summarised. A dev-server 500 replies with a full HTML page,
and printing it raw buried the one line that said what went wrong under kilobytes
of script tags.
This makes the client tolerant of the fault, which is not the same as fixing it:
the real answer on an automation host is to run a production build rather than
`next dev`, so those manifests are written once instead of continuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
focus.py now verifies on Windows rather than assuming activation worked. Windows
declines to raise a window for a process that doesn't own the foreground — it
flashes the taskbar and the call returns as if it succeeded — so the runner reads
the foreground window's process back and reports a failure instead of clicking
into a background window. Same gap that was fixed on macOS earlier.
The runner also declares itself DPI-aware at startup. Without it Windows reports
a virtualised screen size and rescales the coordinates it accepts, while the
browser keeps reporting CSS pixels; on a display at 125% or 150% the two disagree
and clicks drift further off the further they are from the top-left.
Browser windows are matched on the owning process rather than the window title, so
an editor with chrome.js open is no longer mistaken for the browser. Linux now says
window management is unsupported there, rather than reporting no browser found —
pygetwindow has no X11 backend, and "no browser window" reads like Chrome is shut.
The main README gained a section on the AutoBuyer: what the three pieces are, how
to load the extension, and that neither the extension nor the runner reloads
itself when the source changes. That last point has been the cause of most of the
confusing failures so far, so it is called out in Updating too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Repeat. A `repeat` block runs its steps several times, with the count either
fixed in the config or taken from an input the user sets on the dashboard. The
block is unrolled in resolveSteps before the runner sees it, so the runner needs
no loop, the run's total step count stays honest, and every iteration appears in
the log as its own line — a failure on the third purchase reads as "(3/5)"
rather than as an indistinguishable repeat of the first.
Counts are clamped server-side against the automation's declared min/max, and
expansion is capped at 400 steps and three levels of nesting. Each iteration can
be a purchase, so the number is not taken on trust from the client, and the
confirmation dialog names it before anything runs.
skipIfNotFound on a click or type step tolerates an element that is not on the
page — a cookie banner, a modal that only sometimes appears. Only absence is
tolerated. That distinction needed a new NotFoundError: previously a missing
element, an unreachable dashboard, a missing tab and a covered button all
surfaced as the same DashboardError, and skipping that whole class would mean a
step quietly passing while the extension was down.
Orphaned runs are now reaped. Only one run executes at a time, so a run left in
'running' when its runner went away blocked every future run — restarting the
daemon mid-run deadlocked the queue, which is exactly what happened. The
heartbeat decides: a runner that is gone, or up and reporting idle, is not
driving that run whatever the status column says. Gated on the busy flag rather
than elapsed time alone, since a run sitting in a waitFor gate or a sign-in wait
can legitimately go minutes without progress.
Lucid Trading is scaffolded with no automations yet. One match pattern covers
both its hosts — `*.` matches the apex as well as subdomains, confirmed against
a live tab. Its signed-out pattern is `//lucidtrading.com/` rather than
`lucidtrading.com/dashboard`: the leading slashes anchor it to the start of the
host, and without them the substring also matches dash.lucidtrading.com, which
would abort every step while properly signed in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step vocabulary gains `waitFor`: block until a selector exists, then continue.
Nothing is clicked or typed — it is a gate for conditions something outside the
run has to satisfy. Unlike every other step it carries no signed-out guard,
because the things worth gating on often sit on the login page, where that guard
would abort the run at exactly the wrong moment. Honours the dashboard's Stop
button, since a two-minute gate that ignored it would be worse than no gate.
Session handling. A run that lands on the login page must not continue: once
redirected, every selector resolves against a login form, so a click aimed at
"Add Account" hits whatever that form renders in the same place. Runs now detect
the redirect and stop before sending any input, with a distinct SignedOutError
rather than a generic failure.
Two ways out of that state, in order: a firm's `authSteps` run and the failed
step is retried, or — when none are defined — the run pauses for
signedOutWaitSeconds so a human can sign in, then resumes. Auth steps are
verified rather than trusted: they can all "succeed" while the site still
rejects the sign-in, so the session is re-checked before the retry, and the run
stops with "auth steps ran but the session is still signed out" if it did not
take.
That check polls for up to 20s instead of reading once. Submitting a login form
starts a network round trip and then a redirect, so the tab still shows the
login URL for a second or two afterwards; checking immediately failed a sign-in
that was merely in flight, killing run #15 nine seconds after it had actually
worked. Third instance of the same mistake in this system — reading page state
immediately after an action that triggers async navigation.
The runner reports its version in the heartbeat and the dashboard blocks the
buttons when it is behind. A running Python process does not reload when the
source changes, so a stale runner fails on step types it predates; that cost a
debugging round when a navigate step reached a runner that had never heard of
one.
lib/automations.ts carries the Tradeify buy-accounts flow: navigate to the
dashboard, open Add Account, pick the account type and size, enter the account
name, and work through the challenge widget before submitting. Selectors are
authored by hand against the live page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns the autobuyer from a page scraper into something that acts. A dashboard
button queues a run; a desktop process executes it against the real browser.
lib/automations.ts — automations are declarative step lists nested inside the
firm whose site they drive. Steps are click / type / wait / navigate, and they
inherit the firm's tab pattern and URL, so one firm's automation can't act on
another's tab. Adding a button means adding an entry here; the page renders
buttons from the API and the runner receives steps from the server, so neither
needs editing. Runs key on firm:automation — every firm will plausibly have its
own "buy-accounts", and a bare id would resolve to the wrong one.
clicker/runner.py — the daemon behind the buttons. Claims a queued run, works
through the steps, reports each one back for the page's live log. Only one run
executes at a time: two processes driving one physical mouse would interleave
clicks. Heartbeats on its own thread, because a step can block for tens of
seconds and folding the beat into the main loop would show the runner as offline
in the middle of the run it was executing.
clicker/actions.py — one implementation of the safety checks, shared by the CLI
and the runner. Refuses to act when the element is covered by an overlay, when
coordinates fall off-screen, when the browser can't be confirmed frontmost, or
(for type) when the target isn't an editable field.
Typing: uneven human cadence, and the field is read back afterwards and compared
against what was typed — a field that never took focus fails silently and looks
identical to success otherwise. Non-ASCII is rejected because pyautogui skips
those characters without complaint, and newlines because Enter may submit the
form. Typos are deliberately not simulated: a mistyped digit in a trading form
is a real loss, and the correction is the part that can go wrong.
Extension: opens the firm's page when no tab matches, navigates to a specific
page for a navigate step (skipped when already there, so page state survives),
and retries the locate while a freshly loaded React app mounts — `complete` only
means the document loaded.
Staleness reporting, after it cost three debugging rounds: Chrome doesn't reload
an unpacked extension and Python doesn't reload a running process, so both now
report their version. A stale runner gets a red banner naming both versions and
the automation buttons are disabled, rather than failing mid-run on a step type
it predates.
Scale detection is now conservative: a raw OS/browser width ratio is only
trusted when it lands on a real scaling factor. On this multi-monitor desktop
the previous logic would have silently halved every coordinate.
Verified end to end against the live browser: navigate, locate, and a real
click (run #12, all three steps). API round-trips, claim-once semantics, run
cancellation, the heartbeat online/offline lifecycle, motion geometry and
timing, focus activation, and typing verification all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Builds the pipeline the autobuyer needs: see the page, find an element,
click it.
extension/ — MV3 Chromium extension. Polls /api/autobuyer/status and,
while on, scrapes the target tab's HTML and posts it back. Also serves
locate requests: focuses the window, scrolls the element into view, and
reports its position. host_permissions is scoped to tradeify plus
localhost so it cannot read other sites — an empty target pattern would
otherwise capture whatever tab happened to be active, including banking
or mail.
app/api/autobuyer/ — status toggle, capture store, and the locate request
queue. CORS is open because the extension's origin changes every time an
unpacked extension is reloaded.
app/autobuyer/page.tsx — ON switch, source view (default) and a rendered
view. The render uses sandbox="allow-scripts" without allow-same-origin:
the page's own JS is needed because sites ship content at opacity:0 and
fade it in, but the frame must not reach the dashboard's same-origin API
routes, which serve firm credentials.
clicker/ — Python CLI. Asks the extension where a selector is, adds the
element rect to the window's screen position and the browser chrome
height to get desktop coordinates, then clicks with a human motion model
(curved path, eased velocity, occasional overshoot, dwell before press).
Raises the browser application first, since macOS consumes a click on an
unfocused window rather than delivering it.
Refuses to click when the element is covered by an overlay, when the
coordinates fall off-screen, or when the browser cannot be confirmed
frontmost.
Verified: API round-trips, capture pruning, locate claim-once semantics,
motion geometry and timing, and focus activation — the last two against
stubs, since pyautogui and pyobjc are not installed here. NOT verified
end to end: Chrome is still running a stale build of the extension, so a
locate request has never completed against a real page and no real click
has been sent.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Add withdrawal stage system: each stage defines profit target, consistency,
and min trading days for post-withdrawal challenge cycles
- Target Same Equity mode accounts for withdrawn amounts when computing
effective profit target (profitTarget - remainingProfit)
- Store fund transaction timestamps for time-aware cycle filtering
(withdrawals before 9 AM CT include that day in new cycle)
- Expose full P&L history (fullDailyPnL) for calendar/equity curve display
across all cycles, with DB fallback for pre-restart data
- Show stage number (#1, #2, etc.) on calendar cells
- Hide consistency reference line when consistency is 0% or 100%
- Settings UI: "After First W/D" column with same-equity checkbox,
expandable stage sub-rows with profit/consistency/days inputs
- Default target_same_equity to 1 for new and existing account configs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Persist fund transactions to SQLite (fund_transactions table) so they
survive beyond Tradovate's 28-day report window
- Calendar: highlight W/D dates in amber with the amount shown below the day
- Equity curve: reduce running equity at withdrawal dates and show a vertical
dashed amber line labelled W/D
- New Cash History table below calendar listing all trades and W/D events
sorted newest-first
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tracks Fund Transaction entries in the Cash History report to find the
most recent account funding/reset date. Only trading days on or after
that date count toward daysTraded and the daily target calculation.
The last fund date is persisted in SQLite so it survives beyond the
28-day Tradovate report window.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a daily_pnl SQLite table that accumulates trade history indefinitely.
On each hourly fetch, fresh API data is upserted (not replaced) so entries
older than Tradovate's 28-day limit are preserved. Cache is loaded at startup
and used as fallback when both report requests fail.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dropdown on settings page with two options:
- Full CME (5:00 PM – 3:00 PM CT) with 5 min buffer
- Equity Hours (8:30 AM – 3:00 PM CT) with 5 min buffer
Setting is read live each tick, no restart needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New reporter module that pushes firm stats (total accounts, accounts
traded, in trade) to a configurable master dashboard every 30 seconds
- Add Instance Name and Dashboard URL fields to the settings page
- Register master_dashboard_url and instance_name in settings API
- Seed default (empty) values for new settings in db
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Seed tick_interval_seconds setting (default 60s)
- Expose tick_interval_seconds via GET/PATCH /api/settings
- startScheduler reads the setting at start time; enforces 5s minimum
- getSchedulerStatus returns intervalSeconds for the UI
- Main page: "Every [__] s" input in idle bar — saves on blur, persists across
restarts; running state displays "every Ns" next to symbol/action
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- lib/db.ts: new firm_banned_symbols table with getBannedSymbols,
isSymbolBanned, and setBannedSymbol helpers
- app/api/firms/[id]/banned-symbols/route.ts: GET lists banned symbols,
PATCH toggles a ban for a given symbol
- app/api/firms/route.ts: include bannedSymbols[] in firm list response
- app/firms/[id]/settings/page.tsx: Instruments section shows all
globally-enabled symbols with a red toggle to ban/unban; banned
symbols display a "SYMBOL BANNED" pill next to their name
- app/page.tsx: FirmRows shows "ES BANNED" (or current symbol) pill next
to the firm name when the selected trade symbol is banned for that firm
- lib/auto-trade.ts: skip firms entirely when the trade symbol is banned
- types.ts: add bannedSymbols field to FirmConfig
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Plaintext usernames and passwords for Alpha and TakeProfitTrader have
been removed from the startup seed block. Firms must now be added
manually through the UI or API.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DB migration: ALTER TABLE account_configs ADD COLUMN max_position_size INTEGER NOT NULL DEFAULT 0
- Added max_position_size to AccountConfigRow, createAccountConfig, updateAccountConfig in lib/db.ts
- Added maxPositionSize to AccountConfig type in types.ts (0 = no limit)
- GET /api/firms/[id] now returns maxPositionSize per account
- POST /api/firms/[id]/accounts and PUT /api/account-configs/[id] accept maxPositionSize
- Firm settings page: new Max Contracts column (blank = no limit)
- auto-trade: contracts capped at maxPositionSize when > 0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Auto-trade scheduler fires every 60s; uses Promise.allSettled batch so no new trades fire while any position from the current batch is open
- Commission gross-up: read entryCommission from cash.realizedPnL after fill (fallback 2.5×contracts), grossTarget = target + 2×entryCommission
- Sync gate: TradovateClient.syncComplete flag; scheduler skips tick until every client finishes initial position/balance sync
- Contracts formula changed to Math.ceil so $1500 target = 2 contracts
- Removed all fee caching (perContractFees, recentFills, fillFee handler) from tradovate-class.ts
- Removed firm_fees table, getFirmFees, upsertFirmFee from db.ts
- Deleted instrument-configs API routes; removed Fees UI from firm settings page
- /api/instruments returns full {symbol, enabled}[] objects; dashboard filters to enabled-only for trade selector
- Added auto-trade, debug, orders, settings, and trade API routes
- Instrument selector on dashboard now driven by enabled instruments from DB
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>