Let the master dashboard drive this instance; drop the AutoBuyer switch
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0ba27161ce
commit
25289cb9b3
@@ -79,6 +79,38 @@ Open the app in your browser and add your firms through the UI:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## The master dashboard
|
||||||
|
|
||||||
|
Set **Instance Name** and **Dashboard URL** on the Settings page and this
|
||||||
|
instance does two things with them: it reports its firm and account counts every
|
||||||
|
30 seconds, and it holds a WebSocket open to `<url>/ws/instance` so the master
|
||||||
|
can drive it.
|
||||||
|
|
||||||
|
What the master can trigger here:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Trade | Buy / Sell / Auto on a symbol, with the same "stop after all" option as the button on this dashboard |
|
||||||
|
| Stop | Halts the auto-trade scheduler |
|
||||||
|
| Run an automation | Queues a run for `clicker/runner.py`, exactly as the AutoBuyer page does |
|
||||||
|
|
||||||
|
Commands land in [`lib/master-link.ts`](lib/master-link.ts), which calls the same
|
||||||
|
functions the pages call — `runTrade`, `startScheduler`, `queueRun`. Nothing
|
||||||
|
reaches the broker by a path the local UI does not also use, and every command
|
||||||
|
is acked with its result or its error so the master shows what actually happened
|
||||||
|
rather than assuming.
|
||||||
|
|
||||||
|
The link is entirely optional. With no Dashboard URL configured it never starts,
|
||||||
|
and if the master is unreachable the instance keeps trading on its own and falls
|
||||||
|
back to the plain HTTP report until the socket comes back (retrying with backoff,
|
||||||
|
capped at a minute).
|
||||||
|
|
||||||
|
**Nothing authenticates this link in either direction.** Whatever answers at the
|
||||||
|
Dashboard URL can place orders on every account this instance holds, so point it
|
||||||
|
only at a master you control, and prefer `https` so the address cannot be
|
||||||
|
redirected in transit. If the socket will not stay up, the `connected` light on
|
||||||
|
the master is the only symptom — check this instance's logs for `[master-link]`.
|
||||||
|
|
||||||
## AutoBuyer (browser automation)
|
## AutoBuyer (browser automation)
|
||||||
|
|
||||||
The AutoBuyer page drives a real browser to buy and reset prop-firm accounts. It
|
The AutoBuyer page drives a real browser to buy and reset prop-firm accounts. It
|
||||||
@@ -136,8 +168,17 @@ dashboard warns when its version is behind.
|
|||||||
|
|
||||||
### Running it
|
### Running it
|
||||||
|
|
||||||
Turn **Page capture** on from the AutoBuyer page, then press an automation's
|
Press an automation's button. Progress appears per step, and **Stop** halts a run
|
||||||
button. Progress appears per step, and **Stop** halts a run between steps.
|
between steps.
|
||||||
|
|
||||||
|
There is no switch to throw first. Page capture follows whichever firm tab you
|
||||||
|
are looking at and stops when you switch away, and a run starts because someone
|
||||||
|
asked for one — from this page or from the master dashboard. The only thing that
|
||||||
|
gates a run is whether `clicker/runner.py` is up to claim it, which the page
|
||||||
|
shows as **Autoclicker daemon**.
|
||||||
|
|
||||||
|
That does mean a queued run executes as soon as the runner sees it, with no
|
||||||
|
second confirmation. Stop the runner if you want the machine idle.
|
||||||
|
|
||||||
The browser must be visible and frontmost while a run is in flight — the clicks
|
The browser must be visible and frontmost while a run is in flight — the clicks
|
||||||
are real OS-level input, so the machine cannot be used for anything else, and a
|
are real OS-level input, so the machine cannot be used for anything else, and a
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { NextRequest } from 'next/server';
|
||||||
import { FIRMS, findAutomation, automationKey, describeStep, resolveSteps } from '@/lib/automations';
|
import { FIRMS, automationKey, describeStep } from '@/lib/automations';
|
||||||
import { createRun, getSetting } from '@/lib/db';
|
import { queueRun, QueueRunError } from '@/lib/queue-run';
|
||||||
import { corsJson, corsPreflight } from '../cors';
|
import { corsJson, corsPreflight } from '../cors';
|
||||||
|
|
||||||
/** The dashboard renders a tab per firm, and a button per automation inside it. */
|
/** The dashboard renders a tab per firm, and a button per automation inside it. */
|
||||||
@@ -30,38 +30,12 @@ export async function POST(req: NextRequest) {
|
|||||||
if (typeof body.automationId !== 'string') {
|
if (typeof body.automationId !== 'string') {
|
||||||
return corsJson({ error: '`automationId` is required' }, { status: 400 });
|
return corsJson({ error: '`automationId` is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
const found = findAutomation(body.automationId);
|
|
||||||
if (!found) {
|
|
||||||
return corsJson({ error: `No automation "${body.automationId}"` }, { status: 404 });
|
|
||||||
}
|
|
||||||
if (found.automation.steps.length === 0) {
|
|
||||||
return corsJson({ error: `"${found.automation.label}" has no steps yet` }, { status: 400 });
|
|
||||||
}
|
|
||||||
// The dashboard switch is the master arm for anything that drives the mouse.
|
|
||||||
if (getSetting('autobuyer_enabled') !== '1') {
|
|
||||||
return corsJson({ error: 'AutoBuyer is switched off' }, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clamp every input to what the automation declared. The count decides how
|
return corsJson(queueRun(body.automationId, (body.inputs ?? {}) as Record<string, unknown>));
|
||||||
// many times a purchase runs, so it is not taken on trust from the client.
|
|
||||||
const supplied = (body.inputs ?? {}) as Record<string, unknown>;
|
|
||||||
const inputs: Record<string, number> = {};
|
|
||||||
for (const declared of found.automation.inputs ?? []) {
|
|
||||||
const raw = Number(supplied[declared.id]);
|
|
||||||
const value = Number.isFinite(raw) ? Math.floor(raw) : declared.default;
|
|
||||||
inputs[declared.id] = Math.min(declared.max, Math.max(declared.min, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolved;
|
|
||||||
try {
|
|
||||||
resolved = resolveSteps(found.firm, found.automation, inputs);
|
|
||||||
} catch (err: any) {
|
|
||||||
return corsJson({ error: err?.message ?? 'Could not expand the steps' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = createRun(body.automationId, resolved.length, inputs);
|
|
||||||
return corsJson({ runId: run.id, totalSteps: run.total_steps, inputs });
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err instanceof QueueRunError) {
|
||||||
|
return corsJson({ error: err.message }, { status: err.status });
|
||||||
|
}
|
||||||
return corsJson({ error: err?.message ?? 'Failed to queue run' }, { status: 500 });
|
return corsJson({ error: err?.message ?? 'Failed to queue run' }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
import { claimRun, getSetting } from '@/lib/db';
|
import { claimRun } from '@/lib/db';
|
||||||
import { findAutomation, resolveSteps, resolveAuthSteps } from '@/lib/automations';
|
import { findAutomation, resolveSteps, resolveAuthSteps } from '@/lib/automations';
|
||||||
import { corsJson, corsPreflight } from '../../cors';
|
import { corsJson, corsPreflight } from '../../cors';
|
||||||
|
|
||||||
/** The Python runner polls this. Returns the run plus the steps to execute, so
|
/** The Python runner polls this. Returns the run plus the steps to execute, so
|
||||||
* the runner never needs its own copy of the automation definitions. */
|
* the runner never needs its own copy of the automation definitions. */
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
if (getSetting('autobuyer_enabled') !== '1') {
|
|
||||||
return corsJson({ run: null, reason: 'AutoBuyer is switched off' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = claimRun();
|
const row = claimRun();
|
||||||
if (!row) return corsJson({ run: null });
|
if (!row) return corsJson({ run: null });
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,11 @@
|
|||||||
import { NextRequest } from 'next/server';
|
import { countPendingLocate } from '@/lib/db';
|
||||||
import { getSetting, setSetting, countPendingLocate } from '@/lib/db';
|
|
||||||
import { corsJson, corsPreflight } from '../cors';
|
import { corsJson, corsPreflight } from '../cors';
|
||||||
|
|
||||||
/** Polled by the Chromium extension every few seconds, and by the AutoBuyer page.
|
/** Polled by the Chromium extension every few seconds, and by the AutoBuyer page.
|
||||||
* `pendingLocate` rides along so the extension learns about queued clicks without
|
* The AutoBuyer has no on/off switch — it is ready whenever the runner is — so
|
||||||
* a second request on every tick. */
|
* the only thing worth reporting here is whether clicks are waiting to be served. */
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
return corsJson({
|
return corsJson({ pendingLocate: countPendingLocate() });
|
||||||
enabled: getSetting('autobuyer_enabled') === '1',
|
|
||||||
pendingLocate: countPendingLocate(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Flipped by the ON/OFF button on the AutoBuyer page. */
|
|
||||||
export async function PATCH(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json() as { enabled?: unknown };
|
|
||||||
if (typeof body.enabled !== 'boolean') {
|
|
||||||
return corsJson({ error: '`enabled` must be a boolean' }, { status: 400 });
|
|
||||||
}
|
|
||||||
setSetting('autobuyer_enabled', body.enabled ? '1' : '0');
|
|
||||||
return corsJson({ enabled: body.enabled });
|
|
||||||
} catch (err: any) {
|
|
||||||
return corsJson({ error: err?.message ?? 'Failed to set status' }, { status: 500 });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function OPTIONS() {
|
export async function OPTIONS() {
|
||||||
|
|||||||
+9
-55
@@ -115,9 +115,7 @@ function withBaseTag(html: string, url: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AutoBuyer() {
|
export default function AutoBuyer() {
|
||||||
const [enabled, setEnabled] = useState(false);
|
|
||||||
const [capture, setCapture] = useState<Capture | null>(null);
|
const [capture, setCapture] = useState<Capture | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [view, setView] = useState<'rendered' | 'source'>('source');
|
const [view, setView] = useState<'rendered' | 'source'>('source');
|
||||||
// The extension posts a new capture every few seconds, and re-feeding srcDoc would
|
// The extension posts a new capture every few seconds, and re-feeding srcDoc would
|
||||||
@@ -137,10 +135,6 @@ export default function AutoBuyer() {
|
|||||||
|
|
||||||
const poll = useCallback(async () => {
|
const poll = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const s = await fetch('/api/autobuyer/status', { cache: 'no-store' }).then((r) => r.json());
|
|
||||||
setEnabled(!!s.enabled);
|
|
||||||
if (!s.enabled) return;
|
|
||||||
|
|
||||||
const url = lastAtRef.current
|
const url = lastAtRef.current
|
||||||
? `/api/autobuyer/capture?since=${lastAtRef.current}`
|
? `/api/autobuyer/capture?since=${lastAtRef.current}`
|
||||||
: '/api/autobuyer/capture';
|
: '/api/autobuyer/capture';
|
||||||
@@ -236,24 +230,6 @@ export default function AutoBuyer() {
|
|||||||
await pollRuns();
|
await pollRuns();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle() {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/autobuyer/status', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled: !enabled }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
setEnabled(!!data.enabled);
|
|
||||||
setError(null);
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.message ?? 'Failed to toggle');
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRendered() {
|
function showRendered() {
|
||||||
setView('rendered');
|
setView('rendered');
|
||||||
if (capture) setPinned(capture);
|
if (capture) setPinned(capture);
|
||||||
@@ -273,28 +249,11 @@ export default function AutoBuyer() {
|
|||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-slate-900 mb-6">AutoBuyer</h1>
|
<h1 className="text-2xl font-bold text-slate-900 mb-6">AutoBuyer</h1>
|
||||||
|
|
||||||
{/* ── Capture switch and daemon status ── */}
|
{/* ── Daemon status ──
|
||||||
|
There is no on/off switch. Page capture follows whichever firm
|
||||||
|
tab you are looking at, and the AutoBuyer is ready whenever the
|
||||||
|
daemon below is up — so that row is the whole of the state. */}
|
||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-6">
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm mb-6">
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-slate-800">Page capture</p>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
|
||||||
While on, the AutoFirmer Capture extension posts the target tab's HTML here every few seconds
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={toggle}
|
|
||||||
disabled={busy}
|
|
||||||
className={`text-sm px-4 py-1.5 rounded-lg font-medium transition-colors disabled:opacity-50 ${
|
|
||||||
enabled
|
|
||||||
? 'bg-green-500 hover:bg-green-600 text-white'
|
|
||||||
: 'bg-slate-200 hover:bg-slate-300 text-slate-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{busy ? '…' : enabled ? 'ON' : 'OFF'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-4 py-3">
|
<div className="flex items-center justify-between px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-slate-800">Autoclicker daemon</p>
|
<p className="text-sm font-medium text-slate-800">Autoclicker daemon</p>
|
||||||
@@ -411,11 +370,10 @@ export default function AutoBuyer() {
|
|||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => startRun(a)}
|
onClick={() => startRun(a)}
|
||||||
disabled={!enabled || busyRun || activeRun !== null || !runner?.online || !!runner?.stale}
|
disabled={busyRun || activeRun !== null || !runner?.online || !!runner?.stale}
|
||||||
title={
|
title={
|
||||||
!enabled ? 'AutoBuyer is switched off'
|
!runner?.online ? 'The runner is not running'
|
||||||
: !runner?.online ? 'The runner is not running'
|
: runner?.stale ? 'The runner is out of date — restart it'
|
||||||
: runner?.stale ? 'The runner is out of date — restart it'
|
|
||||||
: activeRun !== null ? 'A run is already in progress'
|
: activeRun !== null ? 'A run is already in progress'
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -484,13 +442,9 @@ export default function AutoBuyer() {
|
|||||||
Latest capture
|
Latest capture
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{!enabled ? (
|
{!capture ? (
|
||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
|
||||||
Capture is off
|
Waiting for the extension… switch to a firm tab in the Chrome window that has it installed
|
||||||
</div>
|
|
||||||
) : !capture ? (
|
|
||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-8 text-center text-slate-400">
|
|
||||||
Waiting for the extension… open a page in Chrome with the extension installed
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
|
||||||
|
|||||||
@@ -201,7 +201,8 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-slate-800">Dashboard URL</p>
|
<p className="text-sm font-medium text-slate-800">Dashboard URL</p>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
Reports state to this URL every 30 seconds
|
Reports state here every 30 seconds, and holds a control
|
||||||
|
socket open so the dashboard can trigger trades and buys
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|||||||
+23
-15
@@ -1,7 +1,8 @@
|
|||||||
# AutoFirmer Capture (Chromium extension)
|
# AutoFirmer Capture (Chromium extension)
|
||||||
|
|
||||||
Polls the dashboard for an on/off flag and, while it's on, scrapes the target
|
Scrapes the firm tab you are looking at and posts its HTML back to the
|
||||||
tab's HTML and posts it back to the dashboard.
|
dashboard. There is no on/off switch for that — see **Scope** below for what
|
||||||
|
limits it instead.
|
||||||
|
|
||||||
## Install (unpacked)
|
## Install (unpacked)
|
||||||
|
|
||||||
@@ -12,34 +13,41 @@ tab's HTML and posts it back to the dashboard.
|
|||||||
- **Dashboard URL** — default `http://localhost:3000`. Use the VPS IP if the
|
- **Dashboard URL** — default `http://localhost:3000`. Use the VPS IP if the
|
||||||
dashboard runs elsewhere.
|
dashboard runs elsewhere.
|
||||||
- **Poll interval** — seconds between status checks (default 3).
|
- **Poll interval** — seconds between status checks (default 3).
|
||||||
- **Target URL pattern** — a Chrome match pattern like
|
|
||||||
`https://*.tradovate.com/*`. Leave blank to capture whichever tab is active.
|
|
||||||
- **Element selector** — optional CSS selector; matching elements get their
|
- **Element selector** — optional CSS selector; matching elements get their
|
||||||
on-screen position measured alongside the HTML.
|
on-screen position measured alongside the HTML.
|
||||||
|
|
||||||
The badge shows `ON` (green) while capturing, `!` (red) if the dashboard is
|
The badge shows `!` (red) if the dashboard is unreachable, and nothing otherwise
|
||||||
unreachable, and nothing when the switch is off.
|
— blank is the working state. Open the popup for what the last tick actually did.
|
||||||
|
|
||||||
## Flow
|
## Flow
|
||||||
|
|
||||||
```
|
```
|
||||||
AutoBuyer page --PATCH /api/autobuyer/status--> SQLite settings
|
extension --GET /api/autobuyer/status--> { pendingLocate }
|
||||||
extension --GET /api/autobuyer/status--> { enabled }
|
|
||||||
extension --POST /api/autobuyer/capture-> html + viewport + element rects
|
extension --POST /api/autobuyer/capture-> html + viewport + element rects
|
||||||
AutoBuyer page --GET /api/autobuyer/capture-> renders the HTML
|
AutoBuyer page --GET /api/autobuyer/capture-> renders the HTML
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Nothing in that loop is switchable. Every tick serves any queued clicks and then
|
||||||
|
captures the tab you are on.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
`host_permissions` is deliberately narrow — `https://*.tradeify.co/*` plus localhost
|
Capture has no switch, so scope is what keeps it honest. Two limits, both
|
||||||
for the dashboard. The extension is technically incapable of reading any other site,
|
enforced by Chrome rather than by this code:
|
||||||
so an accidental capture of your bank or mail tab can't happen. The default
|
|
||||||
**Target URL pattern** matches, so it only ever captures the broker tab regardless
|
1. **`host_permissions` is deliberately narrow** — the firm sites plus localhost
|
||||||
of which tab is focused.
|
for the dashboard. The extension is *technically incapable* of reading any
|
||||||
|
other site, so an accidental capture of your bank or mail tab cannot happen
|
||||||
|
however the logic here is changed.
|
||||||
|
2. **Only the tab you are on.** `pickTab()` asks for the active tab of the
|
||||||
|
last-focused window *and* a host_permissions match. A firm tab sitting in the
|
||||||
|
background is not captured, and neither is anything else. Switch away and
|
||||||
|
capture simply stops until you switch back.
|
||||||
|
|
||||||
To automate a different broker, add its pattern to `host_permissions` in
|
To automate a different broker, add its pattern to `host_permissions` in
|
||||||
`manifest.json`, update the popup's target pattern, and reload the extension. Avoid
|
`manifest.json` and reload the extension — that alone scopes capture to it. Avoid
|
||||||
going back to `<all_urls>` — that re-enables scraping whatever tab is active.
|
going back to `<all_urls>`: it would put every tab you visit in reach, and limit
|
||||||
|
1 is the only thing standing between this and scraping your whole browser.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
+37
-28
@@ -1,8 +1,15 @@
|
|||||||
/* AutoFirmer Capture — MV3 service worker.
|
/* AutoFirmer Capture — MV3 service worker.
|
||||||
*
|
*
|
||||||
* Loop: poll GET /api/autobuyer/status. While `enabled` is true, scrape the
|
* Loop: every tick, scrape the tab you are looking at and POST it to
|
||||||
* target tab's HTML and POST it to /api/autobuyer/capture. The dashboard's
|
* /api/autobuyer/capture. The dashboard's AutoBuyer page renders whatever
|
||||||
* AutoBuyer page renders whatever landed there.
|
* landed there.
|
||||||
|
*
|
||||||
|
* Nothing here has a switch. Capture is scoped instead: only the ACTIVE tab of
|
||||||
|
* the last-focused window, and only when that tab is one of the firm sites in
|
||||||
|
* host_permissions. Sit on any other page and nothing is read or sent.
|
||||||
|
*
|
||||||
|
* The locate queue is served whenever clicks are waiting. The AutoBuyer is
|
||||||
|
* ready at all times; a run happens because someone asked for one.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
@@ -31,9 +38,11 @@ async function setState(patch) {
|
|||||||
await chrome.storage.local.set({ state: { ...(prev || {}), ...patch, updatedAt: Date.now() } });
|
await chrome.storage.local.set({ state: { ...(prev || {}), ...patch, updatedAt: Date.now() } });
|
||||||
}
|
}
|
||||||
|
|
||||||
function setBadge(connected, enabled) {
|
/** `!` when the dashboard is unreachable, blank otherwise. A blank badge is the
|
||||||
chrome.action.setBadgeText({ text: !connected ? '!' : enabled ? 'ON' : '' });
|
* working state — there is nothing to switch on, so nothing to advertise. */
|
||||||
chrome.action.setBadgeBackgroundColor({ color: !connected ? '#ef4444' : '#22c55e' });
|
function setBadge(connected) {
|
||||||
|
chrome.action.setBadgeText({ text: connected ? '' : '!' });
|
||||||
|
chrome.action.setBadgeBackgroundColor({ color: '#ef4444' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── The injected scraper ────────────────────────────────────────────────────
|
// ── The injected scraper ────────────────────────────────────────────────────
|
||||||
@@ -106,20 +115,26 @@ function firmPatterns() {
|
|||||||
.filter((p) => !/\/\/(localhost|127\.0\.0\.1)/.test(p));
|
.filter((p) => !/\/\/(localhost|127\.0\.0\.1)/.test(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickTab(cfg) {
|
/** The firm tab you are actually looking at, or nothing.
|
||||||
const tabs = (await chrome.tabs.query({ url: firmPatterns() }))
|
*
|
||||||
.filter((t) => /^https?:/.test(t.url || ''));
|
* Deliberately no fallback to "some other firm tab that happens to be open":
|
||||||
if (tabs.length === 0) return undefined;
|
* capture should report the page in front of you, not a stale background tab
|
||||||
|
* you left on a broker site hours ago. Chrome does the pattern matching, so a
|
||||||
// Prefer the one being looked at, so with two firms open the capture follows
|
* tab outside host_permissions cannot be selected here at all.
|
||||||
// attention rather than tab order.
|
*/
|
||||||
return tabs.find((t) => t.active) ?? tabs[0];
|
async function pickTab() {
|
||||||
|
const [tab] = await chrome.tabs.query({
|
||||||
|
active: true,
|
||||||
|
lastFocusedWindow: true,
|
||||||
|
url: firmPatterns(),
|
||||||
|
});
|
||||||
|
return tab && /^https?:/.test(tab.url || '') ? tab : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function captureAndSend(cfg) {
|
async function captureAndSend(cfg) {
|
||||||
const tab = await pickTab(cfg);
|
const tab = await pickTab();
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
await setState({ connected: true, enabled: true, error: null, waiting: 'No eligible tab to capture' });
|
await setState({ connected: true, error: null, waiting: 'Not on a firm page — nothing captured' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +162,6 @@ async function captureAndSend(cfg) {
|
|||||||
|
|
||||||
await setState({
|
await setState({
|
||||||
connected: true,
|
connected: true,
|
||||||
enabled: true,
|
|
||||||
error: null,
|
error: null,
|
||||||
waiting: null,
|
waiting: null,
|
||||||
lastCapture: { url: result.url, title: result.title, bytes: result.html.length, at: Date.now() },
|
lastCapture: { url: result.url, title: result.title, bytes: result.html.length, at: Date.now() },
|
||||||
@@ -454,24 +468,19 @@ async function serveLocateRequest(cfg) {
|
|||||||
async function tick() {
|
async function tick() {
|
||||||
const cfg = await getConfig();
|
const cfg = await getConfig();
|
||||||
|
|
||||||
let enabled;
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
|
const res = await fetch(`${cfg.apiBase}/api/autobuyer/status`, { cache: 'no-store' });
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
const status = await res.json();
|
const status = await res.json();
|
||||||
enabled = !!status.enabled;
|
|
||||||
pendingLocate = Number(status.pendingLocate) || 0;
|
pendingLocate = Number(status.pendingLocate) || 0;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setBadge(false, false);
|
setBadge(false);
|
||||||
await setState({ connected: false, enabled: false, error: `Cannot reach ${cfg.apiBase} — ${err.message}` });
|
await setState({ connected: false, error: `Cannot reach ${cfg.apiBase} — ${err.message}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBadge(true, enabled);
|
setBadge(true);
|
||||||
if (!enabled) {
|
await setState({ connected: true });
|
||||||
await setState({ connected: true, enabled: false, error: null, waiting: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Serve queued clicks before capturing — the Python side is waiting on these,
|
// Serve queued clicks before capturing — the Python side is waiting on these,
|
||||||
// and a capture of a megabyte page shouldn't sit in front of them.
|
// and a capture of a megabyte page shouldn't sit in front of them.
|
||||||
@@ -482,13 +491,13 @@ async function tick() {
|
|||||||
if (!(await serveLocateRequest(cfg))) break;
|
if (!(await serveLocateRequest(cfg))) break;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await setState({ connected: true, enabled: true, error: `Locate queue: ${err.message}` });
|
await setState({ connected: true, error: `Locate queue: ${err.message}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await captureAndSend(cfg);
|
await captureAndSend(cfg);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await setState({ connected: true, enabled: true, error: err.message });
|
await setState({ connected: true, error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "AutoFirmer Capture",
|
"name": "AutoFirmer Capture",
|
||||||
"version": "0.8.0",
|
"version": "0.10.0",
|
||||||
"description": "Scrapes the HTML of the target tab and posts it to the AutoFirmer dashboard while the AutoBuyer is switched on.",
|
"description": "Scrapes the HTML of the firm tab you are viewing and posts it to the AutoFirmer dashboard.",
|
||||||
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
"permissions": ["scripting", "tabs", "storage", "alarms"],
|
||||||
"host_permissions": [
|
"host_permissions": [
|
||||||
"https://*.tradeify.co/*",
|
"https://*.tradeify.co/*",
|
||||||
|
|||||||
+5
-2
@@ -13,8 +13,11 @@ async function load() {
|
|||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
const { state = {} } = await chrome.storage.local.get('state');
|
const { state = {} } = await chrome.storage.local.get('state');
|
||||||
const color = !state.connected ? '#ef4444' : state.enabled ? '#22c55e' : '#94a3b8';
|
|
||||||
const label = !state.connected ? 'Disconnected' : state.enabled ? 'ON — capturing' : 'Connected — off';
|
// There is nothing to switch on: either it can reach the dashboard or it
|
||||||
|
// cannot. `waiting` below says why a tick captured nothing.
|
||||||
|
const color = state.connected ? '#22c55e' : '#ef4444';
|
||||||
|
const label = state.connected ? 'Capturing' : 'Disconnected';
|
||||||
|
|
||||||
const cap = state.lastCapture;
|
const cap = state.lastCapture;
|
||||||
const detail = state.waiting
|
const detail = state.waiting
|
||||||
|
|||||||
+6
-1
@@ -1,7 +1,8 @@
|
|||||||
import { TradovateClient } from './tradovate-class';
|
import { TradovateClient } from './tradovate-class';
|
||||||
import { getFirms, getInstruments } from './db';
|
import { getFirms, getInstruments } from './db';
|
||||||
import { resolveContracts } from './contract-resolver';
|
import { resolveContracts } from './contract-resolver';
|
||||||
import { startReporter } from './reporter';
|
import { startReporter, sendReport } from './reporter';
|
||||||
|
import { startMasterLink } from './master-link';
|
||||||
import { resumeSchedulerIfPersisted } from './auto-trade';
|
import { resumeSchedulerIfPersisted } from './auto-trade';
|
||||||
|
|
||||||
// Use global to persist the client pool across HMR reloads in dev mode
|
// Use global to persist the client pool across HMR reloads in dev mode
|
||||||
@@ -65,6 +66,10 @@ export function getClients(): Map<number, TradovateClient> {
|
|||||||
// Start master dashboard reporter
|
// Start master dashboard reporter
|
||||||
startReporter();
|
startReporter();
|
||||||
|
|
||||||
|
// ...and the socket it prefers to report over, which is also how
|
||||||
|
// the master triggers trades and AutoBuyer runs here.
|
||||||
|
startMasterLink(() => { void sendReport(); });
|
||||||
|
|
||||||
// Pick the auto-trade schedule back up if it was running before restart
|
// Pick the auto-trade schedule back up if it was running before restart
|
||||||
resumeSchedulerIfPersisted();
|
resumeSchedulerIfPersisted();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -341,8 +341,6 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
seedSetting.run('autobuyer_enabled', '0');
|
|
||||||
|
|
||||||
export interface CaptureRow {
|
export interface CaptureRow {
|
||||||
id: number;
|
id: number;
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Control link to the master dashboard.
|
||||||
|
*
|
||||||
|
* We dial out, not the other way round: this machine sits on a home network
|
||||||
|
* behind NAT and the master is the only end with a public address. Once the
|
||||||
|
* socket is up it carries traffic both ways — reports go up it instead of the
|
||||||
|
* 30s HTTP POST, and trade / AutoBuyer commands come back down it.
|
||||||
|
*
|
||||||
|
* The link is optional. Without `master_dashboard_url` and `instance_name`
|
||||||
|
* nothing starts, and if the master is unreachable the reporter falls back to
|
||||||
|
* the HTTP endpoint it has always used.
|
||||||
|
*
|
||||||
|
* Neither end authenticates the other. Whatever is at `master_dashboard_url`
|
||||||
|
* can place orders on every account this instance holds, so point it only at a
|
||||||
|
* master you control, and prefer https so it cannot be redirected in transit.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSetting, getInstruments, getRunnerHeartbeat, RUNNER_TIMEOUT_MS } from './db';
|
||||||
|
import { runTrade, startScheduler, stopScheduler, getSchedulerStatus } from './auto-trade';
|
||||||
|
import { FIRMS, automationKey } from './automations';
|
||||||
|
import { POINT_VALUES } from './trading-logic';
|
||||||
|
import { queueRun } from './queue-run';
|
||||||
|
import type {
|
||||||
|
Command,
|
||||||
|
InstanceCapabilities,
|
||||||
|
InstanceMessage,
|
||||||
|
MasterMessage,
|
||||||
|
} from './master-protocol';
|
||||||
|
|
||||||
|
const WS_PATH = '/ws/instance';
|
||||||
|
const RECONNECT_MIN_MS = 2_000;
|
||||||
|
const RECONNECT_MAX_MS = 60_000;
|
||||||
|
|
||||||
|
interface LinkState {
|
||||||
|
socket: WebSocket | null;
|
||||||
|
/** Only true once the master has answered the hello. */
|
||||||
|
ready: boolean;
|
||||||
|
attempt: number;
|
||||||
|
reconnectTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
stopped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HMR-safe: a dev reload must not leave a second socket dialling.
|
||||||
|
const _g = globalThis as typeof globalThis & { __masterLink?: LinkState };
|
||||||
|
|
||||||
|
function state(): LinkState {
|
||||||
|
if (!_g.__masterLink) {
|
||||||
|
_g.__masterLink = { socket: null, ready: false, attempt: 0, reconnectTimer: null, stopped: false };
|
||||||
|
}
|
||||||
|
return _g.__masterLink;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── capabilities ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** What this instance can be asked to do, as of right now. */
|
||||||
|
export function collectCapabilities(): InstanceCapabilities {
|
||||||
|
// Only symbols the operator has enabled here. An empty instruments table
|
||||||
|
// means nothing has been configured yet, so offer everything we can price.
|
||||||
|
const enabled = getInstruments().filter((i) => i.enabled).map((i) => i.symbol);
|
||||||
|
|
||||||
|
// Same test the AutoBuyer page uses: `at` is epoch ms from the last ping.
|
||||||
|
const heartbeat = getRunnerHeartbeat();
|
||||||
|
const runnerOnline = heartbeat !== null && Date.now() - heartbeat.at < RUNNER_TIMEOUT_MS;
|
||||||
|
|
||||||
|
return {
|
||||||
|
symbols: enabled.length > 0 ? enabled : Object.keys(POINT_VALUES),
|
||||||
|
automations: FIRMS.flatMap((firm) =>
|
||||||
|
firm.automations
|
||||||
|
.filter((a) => a.steps.length > 0)
|
||||||
|
.map((a) => ({
|
||||||
|
key: automationKey(firm.id, a.id),
|
||||||
|
label: a.label,
|
||||||
|
firm: firm.label,
|
||||||
|
inputs: (a.inputs ?? []).map((i) => ({
|
||||||
|
id: i.id,
|
||||||
|
label: i.label,
|
||||||
|
min: i.min,
|
||||||
|
max: i.max,
|
||||||
|
default: i.default,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
runnerOnline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── commands ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one command and returns whatever the master should show the operator.
|
||||||
|
* Throwing is fine — the caller turns it into a failed ack, which surfaces as
|
||||||
|
* a red banner on the dashboard rather than silence.
|
||||||
|
*/
|
||||||
|
async function execute(command: Command): Promise<unknown> {
|
||||||
|
switch (command.kind) {
|
||||||
|
case 'trade': {
|
||||||
|
if (command.symbol !== 'Auto' && !POINT_VALUES[command.symbol]) {
|
||||||
|
throw new Error(`Unknown symbol: ${command.symbol}`);
|
||||||
|
}
|
||||||
|
// Same order as POST /api/trade: fire once now, then leave the
|
||||||
|
// scheduler running to pick up accounts that were busy.
|
||||||
|
const results = await runTrade(command.action, command.symbol);
|
||||||
|
startScheduler(command.action, command.symbol, command.stopAfterAll ?? false);
|
||||||
|
|
||||||
|
const filled = results
|
||||||
|
.flatMap((r: any) => r.results ?? [])
|
||||||
|
.filter((r: any) => r.status === 'filled').length;
|
||||||
|
return { filled, scheduler: getSchedulerStatus() };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'stop-trading':
|
||||||
|
stopScheduler();
|
||||||
|
return { stopped: true, scheduler: getSchedulerStatus() };
|
||||||
|
|
||||||
|
case 'autobuyer-run':
|
||||||
|
return queueRun(command.automationId, command.inputs ?? {});
|
||||||
|
|
||||||
|
case 'report-now':
|
||||||
|
return { reported: true };
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// A newer master may know commands this build does not. Say so
|
||||||
|
// rather than failing silently.
|
||||||
|
const unknown = command as { kind?: string };
|
||||||
|
throw new Error(`Unsupported command: ${unknown.kind ?? 'unnamed'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── link ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `https://dash.example.com` → `wss://dash.example.com/ws/instance` */
|
||||||
|
function toWsUrl(masterUrl: string): string | null {
|
||||||
|
try {
|
||||||
|
const url = new URL(masterUrl);
|
||||||
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
url.pathname = WS_PATH;
|
||||||
|
url.search = '';
|
||||||
|
url.hash = '';
|
||||||
|
return url.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(message: InstanceMessage): boolean {
|
||||||
|
const s = state();
|
||||||
|
if (!s.socket || !s.ready || s.socket.readyState !== WebSocket.OPEN) return false;
|
||||||
|
try {
|
||||||
|
s.socket.send(JSON.stringify(message));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the report went up the socket, so the caller can skip the POST. */
|
||||||
|
export function pushReport(firms: unknown[], scheduler: unknown): boolean {
|
||||||
|
return send({
|
||||||
|
type: 'report',
|
||||||
|
firms: firms as never,
|
||||||
|
scheduler: scheduler as never,
|
||||||
|
capabilities: collectCapabilities(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLinked(): boolean {
|
||||||
|
return state().ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect(reason: string) {
|
||||||
|
const s = state();
|
||||||
|
if (s.stopped || s.reconnectTimer) return;
|
||||||
|
|
||||||
|
// Exponential backoff with jitter: a master restart brings every instance
|
||||||
|
// back at once, and they should not all land on the same millisecond.
|
||||||
|
const base = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** s.attempt);
|
||||||
|
const delay = Math.round(base * (0.5 + Math.random() / 2));
|
||||||
|
s.attempt++;
|
||||||
|
|
||||||
|
if (s.attempt <= 3 || s.attempt % 10 === 0) {
|
||||||
|
console.log(`[master-link] ${reason} — retrying in ${Math.round(delay / 1000)}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
s.reconnectTimer = setTimeout(() => {
|
||||||
|
s.reconnectTimer = null;
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
const s = state();
|
||||||
|
if (s.stopped || s.socket) return;
|
||||||
|
|
||||||
|
const masterUrl = getSetting('master_dashboard_url');
|
||||||
|
const instance = getSetting('instance_name');
|
||||||
|
if (!masterUrl || !instance) {
|
||||||
|
// Not configured — check again later in case Settings gets filled in.
|
||||||
|
scheduleReconnect('not configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = toWsUrl(masterUrl);
|
||||||
|
if (!url) {
|
||||||
|
scheduleReconnect(`master_dashboard_url is not a URL: ${masterUrl}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let socket: WebSocket;
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(url);
|
||||||
|
} catch (err: any) {
|
||||||
|
scheduleReconnect(err?.message ?? 'could not open socket');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.socket = socket;
|
||||||
|
s.ready = false;
|
||||||
|
|
||||||
|
socket.addEventListener('open', () => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'hello',
|
||||||
|
instance,
|
||||||
|
capabilities: collectCapabilities(),
|
||||||
|
} satisfies InstanceMessage));
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('message', (event) => {
|
||||||
|
let msg: MasterMessage;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(String(event.data));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === 'welcome') {
|
||||||
|
s.ready = true;
|
||||||
|
s.attempt = 0;
|
||||||
|
console.log(`[master-link] connected to ${url} as "${instance}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type !== 'command') return;
|
||||||
|
|
||||||
|
const { id, command } = msg;
|
||||||
|
console.log(`[master-link] command: ${command.kind}`);
|
||||||
|
|
||||||
|
// Deliberately not awaited: a trade walks every account and can take a
|
||||||
|
// while, and the socket must stay free for a Stop in the meantime.
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const result = await execute(command);
|
||||||
|
send({ type: 'ack', id, ok: true, result });
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[master-link] command ${command.kind} failed:`, err?.message ?? err);
|
||||||
|
send({ type: 'ack', id, ok: false, error: err?.message ?? String(err) });
|
||||||
|
}
|
||||||
|
// Whatever just happened, the dashboard's numbers are now stale.
|
||||||
|
reportSoon();
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
const dropped = (reason: string) => {
|
||||||
|
if (s.socket !== socket) return; // superseded
|
||||||
|
s.socket = null;
|
||||||
|
s.ready = false;
|
||||||
|
scheduleReconnect(reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.addEventListener('close', (event) => {
|
||||||
|
dropped(event.reason ? `closed: ${event.reason}` : 'connection closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.addEventListener('error', () => {
|
||||||
|
// 'close' always follows, and carries the better message of the two.
|
||||||
|
if (s.socket === socket && s.ready) console.warn('[master-link] socket error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set by startMasterLink so a command can trigger a report without a cycle. */
|
||||||
|
let reportSoon: () => void = () => { };
|
||||||
|
|
||||||
|
export function startMasterLink(onReportRequested: () => void) {
|
||||||
|
if (typeof WebSocket === 'undefined') {
|
||||||
|
console.warn('[master-link] no global WebSocket in this Node build — control link disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reportSoon = onReportRequested;
|
||||||
|
|
||||||
|
const s = state();
|
||||||
|
s.stopped = false;
|
||||||
|
|
||||||
|
// Idempotent on purpose. getClients() is lazy and starts this link, so the
|
||||||
|
// first command that reaches it re-enters here from *inside* a command the
|
||||||
|
// live socket is waiting to ack. Tearing the socket down there loses the
|
||||||
|
// ack, and the master reports a trade that actually executed as a failure —
|
||||||
|
// which invites pressing the button a second time. Already dialling or
|
||||||
|
// connected means there is nothing to do.
|
||||||
|
if (s.socket || s.reconnectTimer) return;
|
||||||
|
|
||||||
|
connect();
|
||||||
|
console.log('[master-link] started');
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Wire protocol for the control link to the master dashboard.
|
||||||
|
*
|
||||||
|
* Mirror of the block at the bottom of `autotrader-master/types.ts`, which is
|
||||||
|
* the source of truth. The two repos deploy independently, so change both
|
||||||
|
* together — and only ever by adding, since an instance and a master can be
|
||||||
|
* running different versions of this file at the same time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FirmStats {
|
||||||
|
firm: string;
|
||||||
|
totalAccounts: number;
|
||||||
|
accountsTraded: number;
|
||||||
|
inTrade: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SchedulerState {
|
||||||
|
running: boolean;
|
||||||
|
action: 'Buy' | 'Sell' | 'Auto';
|
||||||
|
symbol: string;
|
||||||
|
lastRun: string | null;
|
||||||
|
intervalSeconds: number;
|
||||||
|
stopAfterAll?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AutomationSummary {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
firm: string;
|
||||||
|
inputs: { id: string; label: string; min: number; max: number; default: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InstanceCapabilities {
|
||||||
|
symbols: string[];
|
||||||
|
automations: AutomationSummary[];
|
||||||
|
runnerOnline: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Command =
|
||||||
|
| { kind: 'trade'; action: 'Buy' | 'Sell' | 'Auto'; symbol: string; stopAfterAll?: boolean }
|
||||||
|
| { kind: 'stop-trading' }
|
||||||
|
| { kind: 'autobuyer-run'; automationId: string; inputs?: Record<string, number> }
|
||||||
|
| { kind: 'report-now' };
|
||||||
|
|
||||||
|
export type MasterMessage =
|
||||||
|
| { type: 'welcome' }
|
||||||
|
| { type: 'command'; id: string; command: Command };
|
||||||
|
|
||||||
|
export type InstanceMessage =
|
||||||
|
| { type: 'hello'; instance: string; capabilities: InstanceCapabilities }
|
||||||
|
| { type: 'report'; firms: FirmStats[]; scheduler: SchedulerState; capabilities?: InstanceCapabilities }
|
||||||
|
| { type: 'ack'; id: string; ok: boolean; result?: unknown; error?: string };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* Queuing an AutoBuyer run.
|
||||||
|
*
|
||||||
|
* Lifted out of POST /api/autobuyer/automations so the master dashboard's
|
||||||
|
* control link can queue the same run over its WebSocket without going back
|
||||||
|
* through HTTP — and, more to the point, without a second copy of the checks
|
||||||
|
* that decide how many times a purchase fires.
|
||||||
|
*
|
||||||
|
* Queuing is always allowed. Nothing arms the AutoBuyer any more, so the only
|
||||||
|
* thing standing between a queued run and a moving mouse is whether
|
||||||
|
* clicker/runner.py is up to claim it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createRun } from './db';
|
||||||
|
import { findAutomation, resolveSteps } from './automations';
|
||||||
|
|
||||||
|
export class QueueRunError extends Error {
|
||||||
|
constructor(message: string, readonly status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'QueueRunError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueuedRun {
|
||||||
|
runId: number;
|
||||||
|
totalSteps: number;
|
||||||
|
inputs: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueRun(automationId: string, supplied: Record<string, unknown> = {}): QueuedRun {
|
||||||
|
const found = findAutomation(automationId);
|
||||||
|
if (!found) {
|
||||||
|
throw new QueueRunError(`No automation "${automationId}"`, 404);
|
||||||
|
}
|
||||||
|
if (found.automation.steps.length === 0) {
|
||||||
|
throw new QueueRunError(`"${found.automation.label}" has no steps yet`, 400);
|
||||||
|
}
|
||||||
|
// Clamp every input to what the automation declared. The count decides how
|
||||||
|
// many times a purchase runs, so it is not taken on trust from the client.
|
||||||
|
const inputs: Record<string, number> = {};
|
||||||
|
for (const declared of found.automation.inputs ?? []) {
|
||||||
|
const raw = Number(supplied[declared.id]);
|
||||||
|
const value = Number.isFinite(raw) ? Math.floor(raw) : declared.default;
|
||||||
|
inputs[declared.id] = Math.min(declared.max, Math.max(declared.min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved;
|
||||||
|
try {
|
||||||
|
resolved = resolveSteps(found.firm, found.automation, inputs);
|
||||||
|
} catch (err: any) {
|
||||||
|
throw new QueueRunError(err?.message ?? 'Could not expand the steps', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = createRun(automationId, resolved.length, inputs);
|
||||||
|
return { runId: run.id, totalSteps: run.total_steps, inputs };
|
||||||
|
}
|
||||||
+37
-23
@@ -1,11 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Reporter module — pushes aggregated state to the master dashboard.
|
* Reporter module — pushes aggregated state to the master dashboard.
|
||||||
*
|
*
|
||||||
* Every 30 seconds, collects firm-level stats + scheduler state and POSTs
|
* Every 30 seconds, collects firm-level stats + scheduler state and sends them
|
||||||
* to the configured master_dashboard_url. Silently skips if not configured.
|
* to the configured master_dashboard_url. Silently skips if not configured.
|
||||||
|
*
|
||||||
|
* Two transports, in that order of preference: the control WebSocket in
|
||||||
|
* `master-link.ts` when it is up, and the original POST /api/report when it is
|
||||||
|
* not. The socket already exists for commands, so reusing it saves a request
|
||||||
|
* per tick — but a master on an older build has no socket to offer, and the
|
||||||
|
* POST keeps those working unchanged.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getSetting } from './db';
|
import { getSetting } from './db';
|
||||||
|
import { pushReport } from './master-link';
|
||||||
import { getFirms } from './db';
|
import { getFirms } from './db';
|
||||||
import { getClients } from './clients';
|
import { getClients } from './clients';
|
||||||
import { getSchedulerStatus } from './auto-trade';
|
import { getSchedulerStatus } from './auto-trade';
|
||||||
@@ -69,35 +76,42 @@ function collectFirmStats() {
|
|||||||
// HMR-safe global to avoid duplicate intervals
|
// HMR-safe global to avoid duplicate intervals
|
||||||
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
|
const _g = globalThis as typeof globalThis & { __reporterInterval?: ReturnType<typeof setInterval> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect and send one report. Exported so the control link can fire an
|
||||||
|
* off-cycle one the moment a command has changed something — waiting up to 30s
|
||||||
|
* to see whether your own button worked is the wrong feedback loop.
|
||||||
|
*/
|
||||||
|
export async function sendReport(): Promise<void> {
|
||||||
|
const url = getSetting('master_dashboard_url');
|
||||||
|
const instanceName = getSetting('instance_name');
|
||||||
|
|
||||||
|
if (!url || !instanceName) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firms = collectFirmStats();
|
||||||
|
const scheduler = getSchedulerStatus();
|
||||||
|
|
||||||
|
if (pushReport(firms, scheduler)) return;
|
||||||
|
|
||||||
|
await fetch(`${url}/api/report`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ instance: instanceName, firms, scheduler }),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Master might be down — silently continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function startReporter() {
|
export function startReporter() {
|
||||||
if (_g.__reporterInterval) {
|
if (_g.__reporterInterval) {
|
||||||
clearInterval(_g.__reporterInterval);
|
clearInterval(_g.__reporterInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
const report = async () => {
|
_g.__reporterInterval = setInterval(sendReport, 30_000);
|
||||||
const url = getSetting('master_dashboard_url');
|
|
||||||
const instanceName = getSetting('instance_name');
|
|
||||||
|
|
||||||
if (!url || !instanceName) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const firms = collectFirmStats();
|
|
||||||
const scheduler = getSchedulerStatus();
|
|
||||||
|
|
||||||
await fetch(`${url}/api/report`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ instance: instanceName, firms, scheduler }),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// Master might be down — silently continue
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_g.__reporterInterval = setInterval(report, 30_000);
|
|
||||||
|
|
||||||
// Fire first report after a short delay to let clients sync
|
// Fire first report after a short delay to let clients sync
|
||||||
setTimeout(report, 10_000);
|
setTimeout(sendReport, 10_000);
|
||||||
|
|
||||||
console.log('[reporter] started — reporting every 30s');
|
console.log('[reporter] started — reporting every 30s');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user