Compare commits
2
Commits
bf7e147507
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a5b2a64e7 | ||
|
|
0eb56b3949 |
@@ -3,7 +3,7 @@ import { getSetting, setSetting } from '@/lib/db';
|
|||||||
import { scheduleBackupPush } from '@/lib/backup-sync';
|
import { scheduleBackupPush } from '@/lib/backup-sync';
|
||||||
import { getRequestUserAgent } from '@/lib/request-user-agent';
|
import { getRequestUserAgent } from '@/lib/request-user-agent';
|
||||||
|
|
||||||
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim'] as const;
|
const VALID_KEYS = ['max_concurrent_accounts', 'tick_interval_seconds', 'master_dashboard_url', 'instance_name', 'trading_hours', 'twocaptcha_api_key', 'twocaptcha_proxy', 'master_backup_token', 'proxy_url', 'auto_claim', 'auto_activate'] as const;
|
||||||
type SettingKey = typeof VALID_KEYS[number];
|
type SettingKey = typeof VALID_KEYS[number];
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
@@ -25,10 +25,13 @@ export async function PATCH(req: NextRequest) {
|
|||||||
try {
|
try {
|
||||||
const body = await req.json() as Partial<Record<SettingKey, string | number>>;
|
const body = await req.json() as Partial<Record<SettingKey, string | number>>;
|
||||||
|
|
||||||
// Claims run unless it reads exactly 'off' — say so exactly, and
|
// Claims and activations run unless it reads exactly 'off' — say so
|
||||||
// before anything is written.
|
// exactly, and before anything is written.
|
||||||
if (body.auto_claim !== undefined && body.auto_claim !== null && body.auto_claim !== 'on' && body.auto_claim !== 'off') {
|
for (const key of ['auto_claim', 'auto_activate'] as const) {
|
||||||
return NextResponse.json({ error: "auto_claim is 'on' or 'off'" }, { status: 400 });
|
const v = body[key];
|
||||||
|
if (v !== undefined && v !== null && v !== 'on' && v !== 'off') {
|
||||||
|
return NextResponse.json({ error: `${key} is 'on' or 'off'` }, { status: 400 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const key of VALID_KEYS) {
|
for (const key of VALID_KEYS) {
|
||||||
|
|||||||
+25
-1
@@ -744,6 +744,7 @@ export default function Home() {
|
|||||||
const [tradeLoading, setTradeLoading] = useState(false);
|
const [tradeLoading, setTradeLoading] = useState(false);
|
||||||
const [stopAfterAll, setStopAfterAll] = useState(false);
|
const [stopAfterAll, setStopAfterAll] = useState(false);
|
||||||
const [autoClaim, setAutoClaim] = useState(true);
|
const [autoClaim, setAutoClaim] = useState(true);
|
||||||
|
const [autoActivate, setAutoActivate] = useState(true);
|
||||||
|
|
||||||
const handleSort = (col: SortKey) => {
|
const handleSort = (col: SortKey) => {
|
||||||
if (sortKey === col) {
|
if (sortKey === col) {
|
||||||
@@ -813,9 +814,10 @@ export default function Home() {
|
|||||||
|
|
||||||
fetch('/api/settings')
|
fetch('/api/settings')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null }) => {
|
.then((s: { tick_interval_seconds?: string | null; auto_claim?: string | null; auto_activate?: string | null }) => {
|
||||||
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
|
if (s.tick_interval_seconds != null) setTickInterval(s.tick_interval_seconds);
|
||||||
setAutoClaim(s.auto_claim !== 'off');
|
setAutoClaim(s.auto_claim !== 'off');
|
||||||
|
setAutoActivate(s.auto_activate !== 'off');
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
@@ -1024,6 +1026,28 @@ export default function Home() {
|
|||||||
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-claim payouts</span>
|
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-claim payouts</span>
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
{config.some((f) => f.platform === 'vest') && (
|
||||||
|
<label
|
||||||
|
className="flex items-center gap-1.5 ml-2 cursor-pointer select-none"
|
||||||
|
title="Activate a passed evaluation's funded account as soon as it passes (Vest) — whether or not the scheduler is running"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoActivate}
|
||||||
|
onChange={(e) => {
|
||||||
|
const on = e.target.checked;
|
||||||
|
setAutoActivate(on);
|
||||||
|
fetch('/api/settings', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ auto_activate: on ? 'on' : 'off' }),
|
||||||
|
}).then((r) => { if (!r.ok) setAutoActivate(!on); }).catch(() => setAutoActivate(!on));
|
||||||
|
}}
|
||||||
|
className="rounded border-slate-300 text-emerald-500 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400 whitespace-nowrap">Auto-activate funded</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{hasAnyPosition && (
|
{hasAnyPosition && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -103,6 +103,18 @@ dashboard POST /api/vest/session holds it in memory for the firm's Vest cl
|
|||||||
- The token lives ~15 minutes and the app renews it by itself while the tab is
|
- The token lives ~15 minutes and the app renews it by itself while the tab is
|
||||||
open. The tab is marked not auto-discardable, since a discarded tab stops
|
open. The tab is marked not auto-discardable, since a discarded tab stops
|
||||||
renewing.
|
renewing.
|
||||||
|
- Several Vest tabs each renew on their own, and every renewal moves the login
|
||||||
|
on to a new session (localStorage `vest-auth-session`). A token from before
|
||||||
|
still reads, but Vest refuses to mint an account's token with it (401, code
|
||||||
|
1002), so the bot's orders and history reads fail. Since 0.18.0 the hook
|
||||||
|
records each token's session id, and the relay sends the token Vest's
|
||||||
|
current session holds, whichever tab has it, not the first tab's. A tab
|
||||||
|
hooked by an older build is matched by its token's expiry instead; one that
|
||||||
|
renewed before any hook was in it shows in the popup as "none holds the
|
||||||
|
current session" until it renews again or is reloaded.
|
||||||
|
- A sign-in's answer (`/register`) carries the login's own refresh token, and
|
||||||
|
is taken too since 0.18.0; before, the first token after a sign-in waited
|
||||||
|
for the tab's first renewal.
|
||||||
- When the dashboard has a Vest firm (its credential is the login's wallet)
|
- When the dashboard has a Vest firm (its credential is the login's wallet)
|
||||||
and no Vest tab is open, the extension opens one in the background. The app
|
and no Vest tab is open, the extension opens one in the background. The app
|
||||||
signs back in from its 30-day session. After that the person signs in again
|
signs back in from its 30-day session. After that the person signs in again
|
||||||
|
|||||||
+61
-7
@@ -1204,6 +1204,13 @@ async function tick() {
|
|||||||
// Vest app renewed for itself (~15 minutes each); this lifts it and posts it to
|
// Vest app renewed for itself (~15 minutes each); this lifts it and posts it to
|
||||||
// the dashboard whenever it changes. Nothing here renews or signs anything: the
|
// the dashboard whenever it changes. Nothing here renews or signs anything: the
|
||||||
// session stays the app's.
|
// session stays the app's.
|
||||||
|
//
|
||||||
|
// With several Vest tabs open, each renews on its own, and each renewal moves
|
||||||
|
// the login on to a new session: the other tabs' tokens still read, but Vest
|
||||||
|
// refuses to mint an account's token with them (401, code 1002) — the bot's
|
||||||
|
// history reads and orders fail until the tab it relays renews again. So the
|
||||||
|
// token relayed is the one Vest's current session holds (pickVestSession),
|
||||||
|
// whichever tab it is in, not the first tab's.
|
||||||
|
|
||||||
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
|
const VEST_PATTERN = 'https://next.vestmarkets.com/*';
|
||||||
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
|
const VEST_URL = 'https://next.vestmarkets.com/portfolio';
|
||||||
@@ -1220,10 +1227,47 @@ let vestLastPostAt = 0;
|
|||||||
let vestOpenedAt = 0;
|
let vestOpenedAt = 0;
|
||||||
|
|
||||||
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
|
/** Runs in the Vest tab's MAIN world: the session vest-relay.js holds, while
|
||||||
* it has life left. Self-contained. */
|
* it has life left, and the current session the Vest app keeps for all its
|
||||||
|
* tabs. Self-contained. Version 2's token whenever it holds one: it sees
|
||||||
|
* every login renewal version 1 does once it's in, so a version-1 token it
|
||||||
|
* lacks is an account's (the 0.17.0/0.17.1 hooks kept those too). */
|
||||||
function pageVestSession() {
|
function pageVestSession() {
|
||||||
const s = window.__afVestSession;
|
const live = (s) => (s && typeof s.accessToken === 'string' && s.expiresAt > Date.now() + 30000 ? s : null);
|
||||||
return { hooked: window.__afVestHook === true, session: s && s.expiresAt > Date.now() + 30000 ? s : null };
|
const session = live(window.__afVestLogin) ?? live(window.__afVestSession);
|
||||||
|
let current = null;
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem('vest-auth-session') || 'null');
|
||||||
|
if (raw && typeof raw === 'object') {
|
||||||
|
current = {
|
||||||
|
refreshTokenId: typeof raw.refreshTokenId === 'string' && raw.refreshTokenId ? raw.refreshTokenId : null,
|
||||||
|
accessTokenExpiresAt: Number.isFinite(raw.accessTokenExpiresAt) ? raw.accessTokenExpiresAt : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch { /* no session kept */ }
|
||||||
|
return { hooked: window.__afVestHook === 2, session, current };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a tab's token is the one the Vest app's current session holds:
|
||||||
|
* the same session id — the app's own check before it mints — else, for a
|
||||||
|
* token caught by a version-1 hook (no id), the same expiry, which the app
|
||||||
|
* writes into the session with the token. */
|
||||||
|
function isCurrentVestSession(session, current) {
|
||||||
|
if (!session || !current) return false;
|
||||||
|
if (session.refreshTokenId && current.refreshTokenId) return session.refreshTokenId === current.refreshTokenId;
|
||||||
|
return current.accessTokenExpiresAt !== null && session.expiresAt === current.accessTokenExpiresAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Out of every Vest tab's pageVestSession, the session to relay: the
|
||||||
|
* current one, else — no tab holds it (one renewed before its hook was in)
|
||||||
|
* — the latest to expire, which still reads. `current` is null when no tab
|
||||||
|
* shows the app's session record to judge by. */
|
||||||
|
function pickVestSession(pages) {
|
||||||
|
const live = pages.filter((p) => p && p.session);
|
||||||
|
if (live.length === 0) return null;
|
||||||
|
const current = live.find((p) => isCurrentVestSession(p.session, p.current));
|
||||||
|
if (current) return { session: current.session, current: true };
|
||||||
|
const newest = live.reduce((x, y) => (y.session.expiresAt > x.session.expiresAt ? y : x));
|
||||||
|
return { session: newest.session, current: pages.some((p) => p && p.current) ? false : null };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */
|
/** The wallets the dashboard's Vest firms trade; [] from an older dashboard. */
|
||||||
@@ -1259,7 +1303,8 @@ async function relayVestSession(cfg) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let found = null;
|
// Every tab: the current session may be in any of them.
|
||||||
|
const pages = [];
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
// A discarded tab stops renewing the session; keep Chrome from it.
|
// A discarded tab stops renewing the session; keep Chrome from it.
|
||||||
if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {});
|
if (tab.autoDiscardable !== false) chrome.tabs.update(tab.id, { autoDiscardable: false }).catch(() => {});
|
||||||
@@ -1272,12 +1317,14 @@ async function relayVestSession(cfg) {
|
|||||||
// next renewal is caught.
|
// next renewal is caught.
|
||||||
await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {});
|
await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: 'MAIN', files: ['vest-relay.js'] }).catch(() => {});
|
||||||
}
|
}
|
||||||
if (out?.session) { found = out.session; break; }
|
if (out) pages.push(out);
|
||||||
}
|
}
|
||||||
if (!found) {
|
const picked = pickVestSession(pages);
|
||||||
|
if (!picked) {
|
||||||
await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' });
|
await setState({ vest: 'Vest tab open — waiting for its session (sign in there if it shows Log In)' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const found = picked.session;
|
||||||
|
|
||||||
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
|
const fingerprint = `${found.expiresAt}:${found.accessToken.length}:${found.accessToken.slice(-16)}`;
|
||||||
if (fingerprint === vestLastRelayed && now - vestLastPostAt < VEST_REPOST_MS) return;
|
if (fingerprint === vestLastRelayed && now - vestLastPostAt < VEST_REPOST_MS) return;
|
||||||
@@ -1297,7 +1344,14 @@ async function relayVestSession(cfg) {
|
|||||||
const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000));
|
const mins = Math.max(0, Math.round((found.expiresAt - Date.now()) / 60000));
|
||||||
const note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}`
|
const note = Array.isArray(body.bound) && body.bound.length > 0 ? ` · bound to ${body.bound.join(', ')}`
|
||||||
: body.used ? '' : ' (no Vest firm here uses this wallet)';
|
: body.used ? '' : ' (no Vest firm here uses this wallet)';
|
||||||
await setState({ vest: `Relayed ${short}${note} · good ${mins}m` });
|
// A tab that renewed before its hook was in holds the current session
|
||||||
|
// unseen: until it renews again, orders and history reads can fail —
|
||||||
|
// reloading it hooks it.
|
||||||
|
const tabsNote = picked.current === false
|
||||||
|
? (tabs.length === 1 ? " · its token isn't Vest's current session — reload the Vest tab"
|
||||||
|
: ` · ${tabs.length} Vest tabs and none holds the current session — reload them, or keep one open`)
|
||||||
|
: tabs.length > 1 ? ` · ${tabs.length} Vest tabs${picked.current ? ', relaying the current one' : ''}` : '';
|
||||||
|
await setState({ vest: `Relayed ${short}${note} · good ${mins}m${tabsNote}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2FA: the person's authenticator code for the firm site you are on ───────
|
// ── 2FA: the person's authenticator code for the firm site you are on ───────
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "AutoFirmer Capture",
|
"name": "AutoFirmer Capture",
|
||||||
"version": "0.17.2",
|
"version": "0.18.0",
|
||||||
|
|
||||||
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxdbOMRR+58a9dHlbIKtlALtS3E4eltBGqfTnUc0lzJuWj5ZA4+5YFSdUnxv5N1ogxwpJUGkUV3MnKJuZnTekCmsNwjCy41a20Xtc7jQLHrIBZkesB6cB+d0l9Vo8M6EC3ZMD8Iv6MdAalU3GZh1Hn9vm24NjJ+xUT1qNIVHEcNUIb9evYobR/XHv+JAp6bOFnaLM+3wpyYKe4efMhsz0F86u9thRNhQnraTqUWfnKNpsHPK2zivaYHrziEEWX7Y3EsMHkf2FRhkUe7IDoxeJM49DBToTo9sAJUkcgLeGCGZLo7H3mcxyn/zERYR+rD3k5xdeBtjoZHXFx2+FanLb9wIDAQAB",
|
||||||
"update_url": "https://ext.juicerroom.com/updates.xml",
|
"update_url": "https://ext.juicerroom.com/updates.xml",
|
||||||
|
|||||||
+22
-9
@@ -6,10 +6,16 @@
|
|||||||
*
|
*
|
||||||
* Vest's app renews its own ~15-minute access token by itself: at load, then
|
* Vest's app renews its own ~15-minute access token by itself: at load, then
|
||||||
* whenever one nears expiry (POST /v3/auth/refresh, or /v3/auth/register at
|
* whenever one nears expiry (POST /v3/auth/refresh, or /v3/auth/register at
|
||||||
* sign-in). Those answers carry the token, its expiry and the login's wallet.
|
* sign-in). Those answers carry the token, its expiry, the login's wallet and
|
||||||
* This hook reads them off the app's own XHRs and keeps the latest on
|
* the session it belongs to (refreshTokenId). This hook reads them off the
|
||||||
* `window.__afVestSession`, where background.js collects it and relays it to
|
* app's own XHRs and keeps the latest on `window.__afVestLogin`, where
|
||||||
* the local dashboard, whose Vest client trades with it.
|
* background.js collects it and relays it to the local dashboard, whose Vest
|
||||||
|
* client trades with it.
|
||||||
|
*
|
||||||
|
* Every tab renews on its own, and each renewal moves the login on to a new
|
||||||
|
* session (localStorage `vest-auth-session`): a token from before still reads,
|
||||||
|
* but Vest refuses to mint an account's token with it (401, code 1002). The
|
||||||
|
* session id lets background.js relay the tab whose token is the current one.
|
||||||
*
|
*
|
||||||
* It sends nothing anywhere, changes no request and never renews anything
|
* It sends nothing anywhere, changes no request and never renews anything
|
||||||
* itself: the session stays the app's, refreshed by the app with its own
|
* itself: the session stays the app's, refreshed by the app with its own
|
||||||
@@ -19,14 +25,19 @@
|
|||||||
* /v3/auth/refresh, sending that account's refresh token in the body (the
|
* /v3/auth/refresh, sending that account's refresh token in the body (the
|
||||||
* login's rides in a cookie) and getting it back renewed. Those tokens see
|
* login's rides in a cookie) and getting it back renewed. Those tokens see
|
||||||
* one account only, so they are skipped: relaying one would leave the
|
* one account only, so they are skipped: relaying one would leave the
|
||||||
* dashboard with a single account.
|
* dashboard with a single account. A sign-in's answer carries the login's
|
||||||
|
* own refresh token, and is taken.
|
||||||
|
*
|
||||||
|
* Version 2. A page hooked by version 1 (`__afVestHook === true`, keeping
|
||||||
|
* `__afVestSession`, no session id, no sign-ins) takes this one beside it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
if (window.__afVestHook) return;
|
if (window.__afVestHook === 2) return;
|
||||||
window.__afVestHook = true;
|
window.__afVestHook = 2;
|
||||||
|
|
||||||
const AUTH = /^https:\/\/api-gateway\.hz\.vestmarkets\.com\/v3\/auth\/(refresh|register)(\?|$)/;
|
const AUTH = /^https:\/\/api-gateway\.hz\.vestmarkets\.com\/v3\/auth\/(refresh|register)(\?|$)/;
|
||||||
|
const SIGN_IN = /\/v3\/auth\/register(\?|$)/;
|
||||||
|
|
||||||
/** A request body that carries a refresh token: an account's renewal. */
|
/** A request body that carries a refresh token: an account's renewal. */
|
||||||
function accountRenewal(reqBody) {
|
function accountRenewal(reqBody) {
|
||||||
@@ -38,14 +49,16 @@
|
|||||||
let body;
|
let body;
|
||||||
try { body = JSON.parse(text); } catch { return; }
|
try { body = JSON.parse(text); } catch { return; }
|
||||||
if (!body || typeof body.accessToken !== 'string' || !body.accessToken) return;
|
if (!body || typeof body.accessToken !== 'string' || !body.accessToken) return;
|
||||||
if (typeof body.refreshToken === 'string' && body.refreshToken) return; // an account's token, renewed
|
// A renewal answering with a refresh token is an account's.
|
||||||
|
if (!SIGN_IN.test(url) && typeof body.refreshToken === 'string' && body.refreshToken) return;
|
||||||
const expiresAt = Number(body.accessExpiresAtMs);
|
const expiresAt = Number(body.accessExpiresAtMs);
|
||||||
if (!Number.isFinite(expiresAt)) return;
|
if (!Number.isFinite(expiresAt)) return;
|
||||||
window.__afVestSession = {
|
window.__afVestLogin = {
|
||||||
accessToken: body.accessToken,
|
accessToken: body.accessToken,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
primaryAddr: typeof body.primaryAddr === 'string' ? body.primaryAddr : '',
|
primaryAddr: typeof body.primaryAddr === 'string' ? body.primaryAddr : '',
|
||||||
userId: typeof body.userId === 'string' ? body.userId : '',
|
userId: typeof body.userId === 'string' ? body.userId : '',
|
||||||
|
refreshTokenId: typeof body.refreshTokenId === 'string' && body.refreshTokenId ? body.refreshTokenId : null,
|
||||||
at: Date.now(),
|
at: Date.now(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-5
@@ -72,6 +72,11 @@ interface TradeRuntime {
|
|||||||
sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number };
|
sideReservation?: { side: 'Buy' | 'Sell'; lane: string; lease: number };
|
||||||
/** Account id → when its last payout claim failed (runClaims backoff). */
|
/** Account id → when its last payout claim failed (runClaims backoff). */
|
||||||
claimFailedAt?: Map<number, number>;
|
claimFailedAt?: Map<number, number>;
|
||||||
|
/** Account id → when its last funded-account activation failed
|
||||||
|
* (runActivations backoff). */
|
||||||
|
activateFailedAt?: Map<number, number>;
|
||||||
|
/** Lanes whose runActivations is out (one at a time per lane). */
|
||||||
|
activationsRunning?: Set<string>;
|
||||||
/** Accounts whose exit the broker refused — a bracket while a hard daily
|
/** Accounts whose exit the broker refused — a bracket while a hard daily
|
||||||
* loss limit was on, or a lone take-profit: the position was flattened
|
* loss limit was on, or a lone take-profit: the position was flattened
|
||||||
* and the account sits out the trade date rather than bleed a round trip
|
* and the account sits out the trade date rather than bleed a round trip
|
||||||
@@ -2038,6 +2043,49 @@ async function runClaims(lane: string, ready: FirmClient[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** How long a failed activation waits before it's tried again — a refusal
|
||||||
|
* (Vest's cap on funded accounts, say) holds for a while. */
|
||||||
|
const ACTIVATE_RETRY_MS = 15 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate the funded account of every passed evaluation in the lane that
|
||||||
|
* the bot trades (one with an account type, as runClaims goes by — Vest caps
|
||||||
|
* funded accounts, and one kept by hand isn't the bot's to spend a slot on),
|
||||||
|
* where the platform does that by API (Vest: a passed evaluation waits until
|
||||||
|
* it's asked). It opens and closes nothing — the evaluation no longer
|
||||||
|
* trades, and the funded account shows on the next sync and trades from
|
||||||
|
* there — so it takes no lane mutex, and the scheduler doesn't wait on it.
|
||||||
|
* One run per lane at a time. Run by the scheduler's tick, or by the claim
|
||||||
|
* watcher while the scheduler is off. Off with the auto_activate setting at
|
||||||
|
* 'off'.
|
||||||
|
*/
|
||||||
|
async function runActivations(lane: string, ready: FirmClient[]): Promise<void> {
|
||||||
|
if (getSetting('auto_activate') === 'off') return;
|
||||||
|
const running = (runtime().activationsRunning ??= new Set());
|
||||||
|
if (running.has(lane)) return;
|
||||||
|
running.add(lane);
|
||||||
|
try {
|
||||||
|
for (const { firm, client } of inLane(ready, lane)) {
|
||||||
|
if (typeof client.activatable !== 'function' || typeof client.activateFunded !== 'function') continue;
|
||||||
|
for (const id of client.activatable()) {
|
||||||
|
const acc = client.accountList.find((a) => a.id === id);
|
||||||
|
if (!acc || !getAccountConfig(id).accountType) continue;
|
||||||
|
if (Date.now() - (runtime().activateFailedAt?.get(id) ?? 0) < ACTIVATE_RETRY_MS) continue;
|
||||||
|
try {
|
||||||
|
const { funded } = await client.activateFunded(id);
|
||||||
|
runtime().activateFailedAt?.delete(id);
|
||||||
|
console.log(`[auto-activate] ${firm.name} ${acc.name}: passed — funded account ${funded ?? '(listed on the next sync)'} activated`);
|
||||||
|
} catch (err) {
|
||||||
|
(runtime().activateFailedAt ??= new Map()).set(id, Date.now());
|
||||||
|
console.error(`[auto-activate] ${firm.name} ${acc.name}: activation failed — ${errorMessage(err)}; retrying in ${ACTIVATE_RETRY_MS / 60_000}m`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
running.delete(lane);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** How often auto-claims run while the scheduler is off — its tick's pace. */
|
/** How often auto-claims run while the scheduler is off — its tick's pace. */
|
||||||
const CLAIM_WATCH_MS = 60_000;
|
const CLAIM_WATCH_MS = 60_000;
|
||||||
|
|
||||||
@@ -2049,8 +2097,9 @@ const _claimWatch = globalThis as typeof globalThis & { __claimWatcher?: ReturnT
|
|||||||
* at its target while the bot is off is claimed all the same, before
|
* at its target while the bot is off is claimed all the same, before
|
||||||
* whatever trades it next can take the profit back. While the scheduler
|
* whatever trades it next can take the profit back. While the scheduler
|
||||||
* runs, its own tick claims (first thing in each lane), and this stays out
|
* runs, its own tick claims (first thing in each lane), and this stays out
|
||||||
* of the way. Off with the auto_claim setting at 'off'. Started once, with
|
* of the way. Off with the auto_claim setting at 'off'. Activations of passed
|
||||||
* the clients.
|
* evaluations' funded accounts ride along the same way (auto_activate).
|
||||||
|
* Started once, with the clients.
|
||||||
*/
|
*/
|
||||||
export function startClaimWatcher(): void {
|
export function startClaimWatcher(): void {
|
||||||
if (_claimWatch.__claimWatcher) return;
|
if (_claimWatch.__claimWatcher) return;
|
||||||
@@ -2058,15 +2107,18 @@ export function startClaimWatcher(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function claimTick(): Promise<void> {
|
async function claimTick(): Promise<void> {
|
||||||
if (getState().running || getSetting('auto_claim') === 'off' || _claimWatch.__claimTickBusy) return;
|
if (getState().running || _claimWatch.__claimTickBusy) return;
|
||||||
|
if (getSetting('auto_claim') === 'off' && getSetting('auto_activate') === 'off') return;
|
||||||
_claimWatch.__claimTickBusy = true;
|
_claimWatch.__claimTickBusy = true;
|
||||||
try {
|
try {
|
||||||
// Ready clients only, as the tick: a stale snapshot may hide a
|
// Ready clients only, as the tick: a stale snapshot may hide a
|
||||||
// position. runClaims takes each lane's mutex itself.
|
// position. runClaims takes each lane's mutex itself; each step
|
||||||
|
// checks its own setting.
|
||||||
const { ready } = firmClients();
|
const { ready } = firmClients();
|
||||||
for (const lane of tradableLanes()) {
|
for (const lane of tradableLanes()) {
|
||||||
if (getState().running) return; // the scheduler took over
|
if (getState().running) return; // the scheduler took over
|
||||||
await runClaims(lane, ready);
|
await runClaims(lane, ready);
|
||||||
|
await runActivations(lane, ready);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[auto-claim] ${errorMessage(err)}`);
|
console.error(`[auto-claim] ${errorMessage(err)}`);
|
||||||
@@ -2343,8 +2395,11 @@ export function startScheduler(action: 'Buy' | 'Sell' | 'Auto', symbol: string,
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Payouts first, account by account — a claim doesn't wait
|
// Payouts first, account by account — a claim doesn't wait
|
||||||
// for the rest of the lane to go flat.
|
// for the rest of the lane to go flat. Nor does a passed
|
||||||
|
// evaluation's funded account, and the batch doesn't wait
|
||||||
|
// on it: a slow one would hold back the lane's entry.
|
||||||
await runClaims(lane, ready);
|
await runClaims(lane, ready);
|
||||||
|
void runActivations(lane, ready).catch((err) => console.error(`[auto-activate] ${laneName(lane)}: ${errorMessage(err)}`));
|
||||||
// Skip this lane if any of its accounts still has an open
|
// Skip this lane if any of its accounts still has an open
|
||||||
// position from its previous batch — a batch fires only
|
// position from its previous batch — a batch fires only
|
||||||
// when every (ready) account in the lane is flat.
|
// when every (ready) account in the lane is flat.
|
||||||
|
|||||||
@@ -175,6 +175,14 @@ export interface PlatformClient {
|
|||||||
/** A claim this client made that no good sync has read past yet: the
|
/** A claim this client made that no good sync has read past yet: the
|
||||||
* balance on hand may not show it, so no claim may be judged on it. */
|
* balance on hand may not show it, so no claim may be judged on it. */
|
||||||
claimPending?(accountId: number): boolean;
|
claimPending?(accountId: number): boolean;
|
||||||
|
/** Passed evaluations whose funded account the platform activates by
|
||||||
|
* API (Vest's "Go to funded account"), by account id — less any this
|
||||||
|
* client already asked for that no sync has read back yet. */
|
||||||
|
activatable?(): number[];
|
||||||
|
/** Activate the funded account of a passed evaluation. Resolves once the
|
||||||
|
* platform opened it, with its name when the answer gives one (the next
|
||||||
|
* sync lists it); rejects with the platform's reason otherwise. */
|
||||||
|
activateFunded?(accountId: number): Promise<{ funded: string | null }>;
|
||||||
/** Drop the account's history from memory (the wipe routes), so it reads
|
/** Drop the account's history from memory (the wipe routes), so it reads
|
||||||
* unloaded until the next fetch rebuilds it. */
|
* unloaded until the next fetch rebuilds it. */
|
||||||
forgetHistory(accountId: number, accountName: string): void;
|
forgetHistory(accountId: number, accountName: string): void;
|
||||||
|
|||||||
+215
-19
@@ -54,6 +54,7 @@ import {
|
|||||||
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
vestAccountId, vestAccountName, vestDayOf, lastVestResetMs, nextVestResetMs, parseCapitalAccount, parsePosition, parseOpenOrder,
|
||||||
parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop,
|
parseTxn, bookTxns, awaitingLegs, unknownOpenFill, unrealizedPnl, hourlyVolatility, marketOpen, longClosure, num, toMs, formatDecimal, resetSafeStop,
|
||||||
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION,
|
parseHistoryPosition, jwtClaimStrings, accountScopedToken, VEST_STATUS_ACTIVE, VEST_STOP_SLIPPAGE_FRACTION,
|
||||||
|
vestStatusName, awaitingActivation,
|
||||||
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
|
VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, settleClaims, readClaims, payoutDaysOf, payoutsSince,
|
||||||
} from './vest-helpers';
|
} from './vest-helpers';
|
||||||
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade, OwnClaim } from './vest-helpers';
|
import type { VestCapitalAccount, VestPosition, VestOpenOrder, VestTxn, VestTrade, OwnClaim } from './vest-helpers';
|
||||||
@@ -74,8 +75,22 @@ type Bucket = keyof typeof SPACING_MS;
|
|||||||
const TIMEOUT_MS = { read: 20_000, trade: 15_000, history: 30_000 } as const;
|
const TIMEOUT_MS = { read: 20_000, trade: 15_000, history: 30_000 } as const;
|
||||||
/** A cached account token is renewed this long before it expires. */
|
/** A cached account token is renewed this long before it expires. */
|
||||||
const ACCOUNT_TOKEN_SKEW_MS = 90_000;
|
const ACCOUNT_TOKEN_SKEW_MS = 90_000;
|
||||||
|
/** An activation whose answer never came may still land at Vest: only a
|
||||||
|
* capital list read from this long after it gave up is taken as Vest's
|
||||||
|
* word on it. */
|
||||||
|
const ACTIVATION_UNKNOWN_HOLD_MS = 60_000;
|
||||||
/** The relayed user token must have this long left to start a request. */
|
/** The relayed user token must have this long left to start a request. */
|
||||||
const USER_TOKEN_MIN_MS = 20_000;
|
const USER_TOKEN_MIN_MS = 20_000;
|
||||||
|
/** How long an exit waits for the login's session and its account's token
|
||||||
|
* — the extension relays the session again within a minute — before it
|
||||||
|
* gives up as not sent. */
|
||||||
|
const EXIT_SESSION_WAIT_MS = 90_000;
|
||||||
|
const EXIT_SESSION_POLL_MS = 2_000;
|
||||||
|
/** A refused account-token mint is logged again after this long. */
|
||||||
|
const MINT_REFUSED_LOG_MS = 5 * 60_000;
|
||||||
|
/** How soon an active account with no history yet is read again, apart
|
||||||
|
* from a whole pass's retry. */
|
||||||
|
const NEW_HISTORY_RETRY_MS = 60_000;
|
||||||
const FILL_POLL_MS = 1_000;
|
const FILL_POLL_MS = 1_000;
|
||||||
const FILL_TIMEOUT_MS = 30_000;
|
const FILL_TIMEOUT_MS = 30_000;
|
||||||
const PICK_TTL_MS = 45_000;
|
const PICK_TTL_MS = 45_000;
|
||||||
@@ -238,6 +253,8 @@ export class VestClient implements PlatformClient {
|
|||||||
private seenOpenAt = new Map<number, { positionId: string; at: number; naggedAt: number }>();
|
private seenOpenAt = new Map<number, { positionId: string; at: number; naggedAt: number }>();
|
||||||
private accountTokens = new Map<string, { token: string; expiresAt: number }>();
|
private accountTokens = new Map<string, { token: string; expiresAt: number }>();
|
||||||
private accountTokenFlights = new Map<string, Promise<string>>();
|
private accountTokenFlights = new Map<string, Promise<string>>();
|
||||||
|
/** The account-token refusal last logged, and when. */
|
||||||
|
private mintRefusedNote: { msg: string; at: number } | null = null;
|
||||||
|
|
||||||
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
private syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private watchdogInterval: ReturnType<typeof setInterval> | null = null;
|
private watchdogInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
@@ -258,6 +275,9 @@ export class VestClient implements PlatformClient {
|
|||||||
private lastDaysFetchStartedAt = 0;
|
private lastDaysFetchStartedAt = 0;
|
||||||
private lastDaysFetchSucceededAt = 0;
|
private lastDaysFetchSucceededAt = 0;
|
||||||
private nextDaysFetchRetryAt = 0;
|
private nextDaysFetchRetryAt = 0;
|
||||||
|
/** When the last read of only the accounts with no history yet began. */
|
||||||
|
private lastNewHistoryFetchAt = 0;
|
||||||
|
private newHistoryWarnedAt = 0;
|
||||||
private historyLoaded = false;
|
private historyLoaded = false;
|
||||||
private historyLoadedIds = new Set<number>();
|
private historyLoadedIds = new Set<number>();
|
||||||
private booksHeld = new Set<number>();
|
private booksHeld = new Set<number>();
|
||||||
@@ -294,6 +314,16 @@ export class VestClient implements PlatformClient {
|
|||||||
private lastSurveyAt = 0;
|
private lastSurveyAt = 0;
|
||||||
private unlistedLoggedAt = 0;
|
private unlistedLoggedAt = 0;
|
||||||
private unknownStatusLogged = new Set<string>();
|
private unknownStatusLogged = new Set<string>();
|
||||||
|
/** Passed evaluations whose activation is out right now. */
|
||||||
|
private activationsOut = new Set<number>();
|
||||||
|
/** Passed evaluations Vest answered an activation for: never asked
|
||||||
|
* again, even while Vest's list is slow to stop showing them. */
|
||||||
|
private activated = new Set<number>();
|
||||||
|
/** Account id → when an activation of unknown outcome gave up; held
|
||||||
|
* until a list read ACTIVATION_UNKNOWN_HOLD_MS on says either way. */
|
||||||
|
private activationUnknownAt = new Map<number, number>();
|
||||||
|
/** When the sync whose capital list this client holds started. */
|
||||||
|
private capitalReadStartedAt = 0;
|
||||||
|
|
||||||
constructor(cfg: VestConfig, onSync: () => Promise<void>) {
|
constructor(cfg: VestConfig, onSync: () => Promise<void>) {
|
||||||
this.cfg = cfg;
|
this.cfg = cfg;
|
||||||
@@ -333,16 +363,25 @@ export class VestClient implements PlatformClient {
|
|||||||
if (this.lastSyncAt === 0 || Date.now() - this.lastSyncAt > SYNC_INTERVAL_MS) void this.requestSync();
|
if (this.lastSyncAt === 0 || Date.now() - this.lastSyncAt > SYNC_INTERVAL_MS) void this.requestSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A token for one trading account, minted from the user token and cached. */
|
/** A token for one trading account, minted from the user token and cached.
|
||||||
|
* Vest may refuse the mint for one account (401, code 1002 — closed ones,
|
||||||
|
* and active ones for a while) while the login's own reads go through:
|
||||||
|
* that fails this account's call alone (request's `mint`). */
|
||||||
private async accountToken(uuid: string): Promise<string> {
|
private async accountToken(uuid: string): Promise<string> {
|
||||||
const cached = this.accountTokens.get(uuid);
|
const cached = this.accountTokens.get(uuid);
|
||||||
if (cached && cached.expiresAt - Date.now() > ACCOUNT_TOKEN_SKEW_MS) return cached.token;
|
if (cached && cached.expiresAt - Date.now() > ACCOUNT_TOKEN_SKEW_MS) return cached.token;
|
||||||
const flight = this.accountTokenFlights.get(uuid);
|
const flight = this.accountTokenFlights.get(uuid);
|
||||||
if (flight) return flight;
|
if (flight) return flight;
|
||||||
const p = (async () => {
|
const p = (async () => {
|
||||||
const { data } = await this.request<{ accessToken?: unknown; accessExpiresAtMs?: unknown }>('read', 'POST', '/v3/auth/account-token', {
|
let data: { accessToken?: unknown; accessExpiresAtMs?: unknown } | undefined;
|
||||||
body: { accountId: uuid },
|
try {
|
||||||
});
|
({ data } = await this.request<{ accessToken?: unknown; accessExpiresAtMs?: unknown }>('read', 'POST', '/v3/auth/account-token', {
|
||||||
|
body: { accountId: uuid }, mint: true,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof VestApiError && err.status === 401 && err.code !== 'NO_SESSION') this.noteMintRefused(uuid, err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
const token = typeof data?.accessToken === 'string' ? data.accessToken : '';
|
const token = typeof data?.accessToken === 'string' ? data.accessToken : '';
|
||||||
const expiresAt = num(data?.accessExpiresAtMs) ?? Date.now() + 10 * 60_000;
|
const expiresAt = num(data?.accessExpiresAtMs) ?? Date.now() + 10 * 60_000;
|
||||||
if (!token) throw new Error('/v3/auth/account-token answered without a token');
|
if (!token) throw new Error('/v3/auth/account-token answered without a token');
|
||||||
@@ -353,6 +392,55 @@ export class VestClient implements PlatformClient {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A refused mint, said once in a while — with how long the login's
|
||||||
|
* token had left, the clue to why Vest refuses them — not once per
|
||||||
|
* account per pass. */
|
||||||
|
private noteMintRefused(uuid: string, msg: string): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const last = this.mintRefusedNote;
|
||||||
|
if (last && now - last.at < MINT_REFUSED_LOG_MS && last.msg === msg) return;
|
||||||
|
this.mintRefusedNote = { msg, at: now };
|
||||||
|
const name = this.accountList.find((a) => this.uuidById.get(a.id) === uuid)?.name ?? uuid;
|
||||||
|
const session = vestSessionFor(this.wallet, now, 0);
|
||||||
|
const left = session ? `${Math.round((session.expiresAt - now) / 1000)}s` : 'none';
|
||||||
|
console.warn(`${this.tag} ${name}: Vest refused its account token (${msg}; the login's token has ${left} left) — only that account's calls fail, the session stays`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before an exit: wait for the login's session (the extension relays it
|
||||||
|
* again within a minute) and the account's own token, up to
|
||||||
|
* EXIT_SESSION_WAIT_MS. An exit given up on leaves a position riding
|
||||||
|
* with no stop, so it waits rather than failing at once; one still
|
||||||
|
* without them is OrderNotPlacedError — nothing went out.
|
||||||
|
*/
|
||||||
|
private async readyForExit(accountId: number, what: string): Promise<void> {
|
||||||
|
const uuid = this.uuidById.get(accountId);
|
||||||
|
const name = this.accountList.find((a) => a.id === accountId)?.name ?? String(accountId);
|
||||||
|
const startedAt = Date.now();
|
||||||
|
let held = false;
|
||||||
|
let lastError = 'no Vest session';
|
||||||
|
for (;;) {
|
||||||
|
if (vestSessionFor(this.wallet, Date.now(), USER_TOKEN_MIN_MS)) {
|
||||||
|
try {
|
||||||
|
if (uuid) await this.accountToken(uuid);
|
||||||
|
if (held) console.log(`${this.tag} ${name}: ${what} — ready after ${Math.round((Date.now() - startedAt) / 1000)}s, sending`);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = errorMessage(err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastError = 'no Vest session';
|
||||||
|
}
|
||||||
|
const waited = Date.now() - startedAt;
|
||||||
|
if (this.intentionalDisconnect || waited + EXIT_SESSION_POLL_MS > EXIT_SESSION_WAIT_MS) {
|
||||||
|
throw new OrderNotPlacedError(`${what} not sent — ${lastError}${held ? ` (waited ${Math.round(waited / 1000)}s)` : ''}`);
|
||||||
|
}
|
||||||
|
if (!held) console.warn(`${this.tag} ${name}: ${what} held — ${lastError}; waiting up to ${EXIT_SESSION_WAIT_MS / 1000}s before sending`);
|
||||||
|
held = true;
|
||||||
|
await sleep(EXIT_SESSION_POLL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── transport ────────────────────────────────────────────────────────────
|
// ── transport ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async gate(bucket: Bucket): Promise<void> {
|
private async gate(bucket: Bucket): Promise<void> {
|
||||||
@@ -370,13 +458,14 @@ export class VestClient implements PlatformClient {
|
|||||||
* An account token that can't be minted is VestNotSentError: the call
|
* An account token that can't be minted is VestNotSentError: the call
|
||||||
* itself never went out. A refused account token is re-minted once; a
|
* itself never went out. A refused account token is re-minted once; a
|
||||||
* refused user token is dropped, so nothing keeps knocking with it until
|
* refused user token is dropped, so nothing keeps knocking with it until
|
||||||
* a fresh one arrives.
|
* a fresh one arrives — but not on a refused mint (`mint`), which is one
|
||||||
|
* account's: the login's own reads go on through on the same token.
|
||||||
*/
|
*/
|
||||||
private async request<T>(
|
private async request<T>(
|
||||||
bucket: Bucket,
|
bucket: Bucket,
|
||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
path: string,
|
path: string,
|
||||||
opts: { params?: Record<string, string | number | boolean>; body?: unknown; account?: string; auth?: boolean; retried?: boolean } = {},
|
opts: { params?: Record<string, string | number | boolean>; body?: unknown; account?: string; auth?: boolean; retried?: boolean; mint?: boolean } = {},
|
||||||
): Promise<{ data: T }> {
|
): Promise<{ data: T }> {
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
if (opts.auth !== false) {
|
if (opts.auth !== false) {
|
||||||
@@ -421,9 +510,12 @@ export class VestClient implements PlatformClient {
|
|||||||
if (opts.account) {
|
if (opts.account) {
|
||||||
this.accountTokens.delete(opts.account);
|
this.accountTokens.delete(opts.account);
|
||||||
if (!opts.retried) return this.request<T>(bucket, method, path, { ...opts, retried: true });
|
if (!opts.retried) return this.request<T>(bucket, method, path, { ...opts, retried: true });
|
||||||
} else {
|
} else if (!opts.mint) {
|
||||||
// The user token itself was refused: forget it, and wait for the
|
// The user token itself was refused: forget it, and wait for the
|
||||||
// tab's next renewal rather than retrying it.
|
// tab's next renewal rather than retrying it. (A refused mint
|
||||||
|
// dropped it too until 2026-10-29, and every account after it
|
||||||
|
// in a history pass failed "no Vest session" until the
|
||||||
|
// extension relayed it again.)
|
||||||
dropVestSession(this.wallet, token);
|
dropVestSession(this.wallet, token);
|
||||||
this.accountTokens.clear();
|
this.accountTokens.clear();
|
||||||
this.consecutiveAuthFailures++;
|
this.consecutiveAuthFailures++;
|
||||||
@@ -607,7 +699,8 @@ export class VestClient implements PlatformClient {
|
|||||||
this.restorePendingOpen(id, vestAccountName(a));
|
this.restorePendingOpen(id, vestAccountName(a));
|
||||||
if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) {
|
if (a.status !== VEST_STATUS_ACTIVE && !this.unknownStatusLogged.has(`${a.id}:${a.status}`)) {
|
||||||
this.unknownStatusLogged.add(`${a.id}:${a.status}`);
|
this.unknownStatusLogged.add(`${a.id}:${a.status}`);
|
||||||
console.warn(`${this.tag} ${vestAccountName(a)} has status ${a.status} — not trading it until that status is known`);
|
if (awaitingActivation(a)) console.log(`${this.tag} ${vestAccountName(a)} passed — its funded account waits on activation`);
|
||||||
|
else console.warn(`${this.tag} ${vestAccountName(a)} is ${vestStatusName(a.status)} — not trading it`);
|
||||||
}
|
}
|
||||||
const acc: PlatformAccount = { id, name: vestAccountName(a), active: a.status === VEST_STATUS_ACTIVE };
|
const acc: PlatformAccount = { id, name: vestAccountName(a), active: a.status === VEST_STATUS_ACTIVE };
|
||||||
list.push(acc);
|
list.push(acc);
|
||||||
@@ -626,7 +719,14 @@ export class VestClient implements PlatformClient {
|
|||||||
this.adoptUnknownOpen(id, mine, startedAt);
|
this.adoptUnknownOpen(id, mine, startedAt);
|
||||||
|
|
||||||
const amount = free.get(a.id);
|
const amount = free.get(a.id);
|
||||||
if (amount === undefined) { complete = false; continue; }
|
// Only a live account's balance is owed: Vest's active list
|
||||||
|
// leaves out a passed evaluation's (seen 2026-10-29), and
|
||||||
|
// waiting on it kept every sync short of good — the lane held,
|
||||||
|
// and nothing ran that would activate it.
|
||||||
|
if (amount === undefined) {
|
||||||
|
if (a.status === VEST_STATUS_ACTIVE) complete = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
|
const balance = amount + mine.reduce((s, p) => s + p.collateral, 0);
|
||||||
this.recordOpenTrades(acc, mine);
|
this.recordOpenTrades(acc, mine);
|
||||||
if (mine.length === 0) {
|
if (mine.length === 0) {
|
||||||
@@ -655,6 +755,7 @@ export class VestClient implements PlatformClient {
|
|||||||
this.numbersAt = now;
|
this.numbersAt = now;
|
||||||
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
|
const shrank = this.accountList.some((a) => !list.some((b) => b.id === a.id));
|
||||||
this.accountList = list;
|
this.accountList = list;
|
||||||
|
this.capitalReadStartedAt = startedAt;
|
||||||
if (shrank) void this.closeOutDropped();
|
if (shrank) void this.closeOutDropped();
|
||||||
// An account gone from Vest's list (breached, closed) takes its
|
// An account gone from Vest's list (breached, closed) takes its
|
||||||
// position with it — a stale one here would hold the lane.
|
// position with it — a stale one here would hold the lane.
|
||||||
@@ -971,6 +1072,74 @@ export class VestClient implements PlatformClient {
|
|||||||
return { claimed, received };
|
return { claimed, received };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** See PlatformClient: the passed evaluations on Vest's list whose funded
|
||||||
|
* account waits on activation, less any this client's asks hold. */
|
||||||
|
public activatable(): number[] {
|
||||||
|
return this.accountList.flatMap((acc) => {
|
||||||
|
const meta = this.capital.get(acc.id);
|
||||||
|
return meta && awaitingActivation(meta) && !this.activationHeld(acc.id) ? [acc.id] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An ask of this client's settles the evaluation until Vest's list can
|
||||||
|
* speak for it: one out now, one Vest answered (for good), or one whose
|
||||||
|
* answer never came, until a list read a while after it gave up. The
|
||||||
|
* list a sync reads while an ask is out predates Vest's answer. */
|
||||||
|
private activationHeld(accountId: number): boolean {
|
||||||
|
if (this.activationsOut.has(accountId) || this.activated.has(accountId)) return true;
|
||||||
|
const unknownAt = this.activationUnknownAt.get(accountId);
|
||||||
|
return unknownAt !== undefined && this.capitalReadStartedAt < unknownAt + ACTIVATION_UNKNOWN_HOLD_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See PlatformClient. Vest's "Go to funded account": POST
|
||||||
|
* /v3/capital/activate-funded with the evaluation's own token, as the Vest
|
||||||
|
* app sends it. Vest answers with the funded account it opened, and marks
|
||||||
|
* the evaluation CLAIMED. Nothing to pay and no terms to accept; Vest
|
||||||
|
* refuses one past its cap on funded accounts.
|
||||||
|
*
|
||||||
|
* One ask at a time per evaluation, and none after Vest answered one. One
|
||||||
|
* that timed out may have gone in: none until a list read a minute on
|
||||||
|
* says either way. A refusal frees it at once.
|
||||||
|
*/
|
||||||
|
public async activateFunded(accountId: number): Promise<{ funded: string | null }> {
|
||||||
|
const uuid = this.uuidById.get(accountId);
|
||||||
|
const account = this.accountList.find((a) => a.id === accountId);
|
||||||
|
const meta = this.capital.get(accountId);
|
||||||
|
if (!uuid || !account || !meta) throw new Error('account not read yet');
|
||||||
|
if (!awaitingActivation(meta)) {
|
||||||
|
throw new Error(`Vest shows ${account.name} as ${meta.kind || 'not an evaluation'}, ${vestStatusName(meta.status)} — only a passed evaluation's funded account is activated`);
|
||||||
|
}
|
||||||
|
if (this.activationHeld(accountId)) throw new Error("the last activation hasn't been read back from Vest yet");
|
||||||
|
|
||||||
|
// Taken before the first await: a second caller meanwhile is refused.
|
||||||
|
this.activationsOut.add(accountId);
|
||||||
|
let data: { account?: unknown } | undefined;
|
||||||
|
try {
|
||||||
|
({ data } = await this.request<{ account?: unknown }>('trade', 'POST', '/v3/capital/activate-funded', {
|
||||||
|
account: uuid,
|
||||||
|
body: { eval_account_id: uuid },
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
if (!nothingWentIn(err)) {
|
||||||
|
this.activationUnknownAt.set(accountId, Date.now());
|
||||||
|
console.error(`${this.tag} ${account.name}: activation — outcome unknown (${errorMessage(err)}); not asked again until Vest's list says either way`);
|
||||||
|
void this.syncRunning.then(() => this.requestSync());
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
this.activationsOut.delete(accountId);
|
||||||
|
}
|
||||||
|
this.activationUnknownAt.delete(accountId);
|
||||||
|
this.activated.add(accountId);
|
||||||
|
const opened = parseCapitalAccount(data?.account);
|
||||||
|
const funded = opened ? vestAccountName(opened) : null;
|
||||||
|
console.log(`${this.tag} ${account.name}: activated its funded account${funded ? ` ${funded}` : ''}`);
|
||||||
|
// A fresh sync lists it — after any one already out, whose reads predate this.
|
||||||
|
void this.syncRunning.then(() => this.requestSync());
|
||||||
|
return { funded };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See PlatformClient: a claim of this client's that Vest's transactions
|
* See PlatformClient: a claim of this client's that Vest's transactions
|
||||||
* don't show yet — until they do, the balance on hand can't be trusted
|
* don't show yet — until they do, the balance on hand can't be trusted
|
||||||
@@ -1349,8 +1518,12 @@ export class VestClient implements PlatformClient {
|
|||||||
const retryPending = this.nextDaysFetchRetryAt > 0;
|
const retryPending = this.nextDaysFetchRetryAt > 0;
|
||||||
const retryDue = retryPending && now >= this.nextDaysFetchRetryAt;
|
const retryDue = retryPending && now >= this.nextDaysFetchRetryAt;
|
||||||
const hourlyRefreshDue = !retryPending && this.lastDaysFetchSucceededAt > 0 && now - this.lastDaysFetchSucceededAt >= 60 * 60_000;
|
const hourlyRefreshDue = !retryPending && this.lastDaysFetchSucceededAt > 0 && now - this.lastDaysFetchSucceededAt >= 60 * 60_000;
|
||||||
const waiting = !initialFetchDue && !retryPending
|
// An active account with no history yet can't trade: it is read on
|
||||||
? this.accountList.filter((a) => !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id)
|
// its own every NEW_HISTORY_RETRY_MS, a whole pass's retry pending or
|
||||||
|
// not — on 2026-10-29 five new accounts sat out retries of passes
|
||||||
|
// that failed. An inactive one waits for a whole pass.
|
||||||
|
const waiting = !initialFetchDue && now - this.lastNewHistoryFetchAt >= NEW_HISTORY_RETRY_MS
|
||||||
|
? this.accountList.filter((a) => a.active && !this.historyLoadedIds.has(a.id) && !this.fetchingIds.has(a.id)).map((a) => a.id)
|
||||||
: [];
|
: [];
|
||||||
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
|
const targeted = !retryDue && !hourlyRefreshDue && !initialFetchDue && waiting.length > 0;
|
||||||
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
|
if (!initialFetchDue && !retryDue && !hourlyRefreshDue && !targeted) return;
|
||||||
@@ -1358,6 +1531,7 @@ export class VestClient implements PlatformClient {
|
|||||||
this.daysFetchInFlight = true;
|
this.daysFetchInFlight = true;
|
||||||
this.daysFetchInFlightSince = now;
|
this.daysFetchInFlightSince = now;
|
||||||
this.lastDaysFetchStartedAt = now;
|
this.lastDaysFetchStartedAt = now;
|
||||||
|
if (targeted) this.lastNewHistoryFetchAt = now;
|
||||||
if (retryDue) this.nextDaysFetchRetryAt = 0;
|
if (retryDue) this.nextDaysFetchRetryAt = 0;
|
||||||
|
|
||||||
this.fetchDaysTraded(targeted ? waiting : undefined)
|
this.fetchDaysTraded(targeted ? waiting : undefined)
|
||||||
@@ -1366,6 +1540,14 @@ export class VestClient implements PlatformClient {
|
|||||||
this.historyLoaded = true;
|
this.historyLoaded = true;
|
||||||
void this.requestSync();
|
void this.requestSync();
|
||||||
}
|
}
|
||||||
|
if (failedAccounts > 0 && targeted) {
|
||||||
|
// Read again on their own within the minute: said now and then.
|
||||||
|
if (Date.now() - this.newHistoryWarnedAt >= 5 * 60_000) {
|
||||||
|
this.newHistoryWarnedAt = Date.now();
|
||||||
|
console.warn(`${this.tag} ${failedAccounts} new account(s) failed history fetch — reading them again every minute`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (failedAccounts > 0) {
|
if (failedAccounts > 0) {
|
||||||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
||||||
console.warn(`${this.tag} ${failedAccounts} account(s) failed history fetch — retrying in 5 minutes`);
|
console.warn(`${this.tag} ${failedAccounts} account(s) failed history fetch — retrying in 5 minutes`);
|
||||||
@@ -1374,8 +1556,8 @@ export class VestClient implements PlatformClient {
|
|||||||
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
|
if (!targeted) this.lastDaysFetchSucceededAt = Date.now();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
if (!targeted) this.nextDaysFetchRetryAt = Date.now() + 5 * 60_000;
|
||||||
console.error(`${this.tag} history fetch failed — retrying in 5 minutes: ${errorMessage(err)}`);
|
console.error(`${this.tag} history fetch failed — ${targeted ? 'reading the new account(s) again within a minute' : 'retrying in 5 minutes'}: ${errorMessage(err)}`);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.daysFetchInFlight = false;
|
this.daysFetchInFlight = false;
|
||||||
@@ -1383,8 +1565,16 @@ export class VestClient implements PlatformClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read the accounts' history: those with none yet first (they can't
|
||||||
|
* trade until it's read), then the other active ones, the inactive
|
||||||
|
* last. Only an active account's failure counts — an inactive one's is
|
||||||
|
* noted, and waits for the hourly pass. */
|
||||||
public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> {
|
public async fetchDaysTraded(onlyAccountIds?: number[]): Promise<{ failedAccounts: number }> {
|
||||||
const ids = this.accountList.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id)).map((a) => a.id);
|
const rank = (a: PlatformAccount) => (!a.active ? 2 : this.historyLoadedIds.has(a.id) ? 1 : 0);
|
||||||
|
const ids = this.accountList
|
||||||
|
.filter((a) => !onlyAccountIds || onlyAccountIds.includes(a.id))
|
||||||
|
.sort((x, y) => rank(x) - rank(y))
|
||||||
|
.map((a) => a.id);
|
||||||
for (const id of ids) this.fetchingIds.add(id);
|
for (const id of ids) this.fetchingIds.add(id);
|
||||||
this.fetchDaysComplete = false;
|
this.fetchDaysComplete = false;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -1396,7 +1586,7 @@ export class VestClient implements PlatformClient {
|
|||||||
try {
|
try {
|
||||||
this.bookHistory(account, await this.fetchTxns(this.uuidById.get(id)!));
|
this.bookHistory(account, await this.fetchTxns(this.uuidById.get(id)!));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
failed++;
|
if (account.active) failed++;
|
||||||
this.fallBackToCache(account, errorMessage(err));
|
this.fallBackToCache(account, errorMessage(err));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -1447,9 +1637,10 @@ export class VestClient implements PlatformClient {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const acc = { id: a.accountId, name: a.accountName, active: false };
|
const acc = { id: a.accountId, name: a.accountName, active: false };
|
||||||
// A closed account may get no token of its own: the
|
// A closed account gets no token of its own (Vest refuses
|
||||||
// login's reads it as the app's history tab does.
|
// the mint): the login's reads it as the app's history
|
||||||
await this.fetchTrades(acc).catch(() => this.fetchTrades(acc, true));
|
// tab does, and its own token is the fallback.
|
||||||
|
await this.fetchTrades(acc, true).catch(() => this.fetchTrades(acc));
|
||||||
console.log(`${this.tag} ${a.accountName}: off Vest's active list — read its position history one last time`);
|
console.log(`${this.tag} ${a.accountName}: off Vest's active list — read its position history one last time`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`${this.tag} ${a.accountName}: off Vest's active list, and its position history read failed: ${errorMessage(err)}`);
|
console.warn(`${this.tag} ${a.accountName}: off Vest's active list, and its position history read failed: ${errorMessage(err)}`);
|
||||||
@@ -1931,6 +2122,9 @@ export class VestClient implements PlatformClient {
|
|||||||
price?: number,
|
price?: number,
|
||||||
intent?: OrderIntent,
|
intent?: OrderIntent,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
|
// A close (a flatten, most of all) waits out a lapse in the session;
|
||||||
|
// an open goes now or not at all.
|
||||||
|
if (intent === 'close') await this.readyForExit(accountId, 'close');
|
||||||
const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol);
|
const { uuid, symbol, step, spec } = this.orderContext(accountId, contractSymbol);
|
||||||
const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
const side: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
||||||
let positions: VestPosition[];
|
let positions: VestPosition[];
|
||||||
@@ -2174,6 +2368,7 @@ export class VestClient implements PlatformClient {
|
|||||||
orderType: 'Market' | 'Limit',
|
orderType: 'Market' | 'Limit',
|
||||||
price?: number,
|
price?: number,
|
||||||
): Promise<{ orderId?: number }> {
|
): Promise<{ orderId?: number }> {
|
||||||
|
await this.readyForExit(accountId, orderType === 'Limit' && price !== undefined ? 'take-profit' : 'close');
|
||||||
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
||||||
const exitSide: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
const exitSide: 'long' | 'short' = action === 'Buy' ? 'long' : 'short';
|
||||||
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
||||||
@@ -2203,6 +2398,7 @@ export class VestClient implements PlatformClient {
|
|||||||
limitPrice: number,
|
limitPrice: number,
|
||||||
stopPrice: number,
|
stopPrice: number,
|
||||||
): Promise<{ orderId?: number }> {
|
): Promise<{ orderId?: number }> {
|
||||||
|
await this.readyForExit(accountId, 'exits');
|
||||||
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
const { uuid, symbol, spec } = this.orderContext(accountId, contractSymbol);
|
||||||
const exitSide: 'long' | 'short' = exitAction === 'Buy' ? 'long' : 'short';
|
const exitSide: 'long' | 'short' = exitAction === 'Buy' ? 'long' : 'short';
|
||||||
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
const pos = await this.heldPosition(uuid, symbol, exitSide);
|
||||||
|
|||||||
+51
-4
@@ -211,7 +211,15 @@ export interface VestCapitalAccount {
|
|||||||
productType: string;
|
productType: string;
|
||||||
/** 1-based stage; stages[stage-1] is the current one. */
|
/** 1-based stage; stages[stage-1] is the current one. */
|
||||||
stage: number;
|
stage: number;
|
||||||
|
/** Evaluation steps the plan has (a 1-Step's is 1, a 2-Step's 2). */
|
||||||
stepsTotal: number;
|
stepsTotal: number;
|
||||||
|
/** The evaluation step this account is (1-based; a funded one's is
|
||||||
|
* stepsTotal + 1, as the Vest app counts it when Vest leaves it out). */
|
||||||
|
step: number;
|
||||||
|
/** Vest's account_type: 2 an evaluation, 3 a funded account. */
|
||||||
|
accountType: number;
|
||||||
|
/** Vest blocked the account (blocked_at set): nothing it offers applies. */
|
||||||
|
blocked: boolean;
|
||||||
/** The current stage: 'eval' or 'funded'. */
|
/** The current stage: 'eval' or 'funded'. */
|
||||||
kind: string;
|
kind: string;
|
||||||
status: number;
|
status: number;
|
||||||
@@ -236,13 +244,18 @@ export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
|
|||||||
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
|
const stages = Array.isArray(a.stages) ? (a.stages as Record<string, unknown>[]) : [];
|
||||||
const stage = num(a.stage) ?? 1;
|
const stage = num(a.stage) ?? 1;
|
||||||
const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {};
|
const current = stages.find((s) => num(s.ordinal) === stage) ?? stages[stage - 1] ?? {};
|
||||||
|
// The Vest app's own fallbacks, for an answer that leaves these out.
|
||||||
|
const stepsTotal = num(a.steps_total) ?? (a.plan_product_type === 'eval' ? 1 : 0);
|
||||||
return {
|
return {
|
||||||
id: a.id,
|
id: a.id,
|
||||||
planId: typeof a.plan_id === 'string' ? a.plan_id : '',
|
planId: typeof a.plan_id === 'string' ? a.plan_id : '',
|
||||||
planName: typeof a.plan_name === 'string' ? a.plan_name : '',
|
planName: typeof a.plan_name === 'string' ? a.plan_name : '',
|
||||||
productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '',
|
productType: typeof a.plan_product_type === 'string' ? a.plan_product_type : '',
|
||||||
stage,
|
stage,
|
||||||
stepsTotal: num(a.steps_total) ?? 0,
|
stepsTotal,
|
||||||
|
step: num(a.step) ?? (stage === VEST_STAGE_FUNDED ? stepsTotal + 1 : 1),
|
||||||
|
accountType: num(a.account_type) ?? 0,
|
||||||
|
blocked: Boolean(a.blocked_at),
|
||||||
kind: typeof current.kind === 'string' ? current.kind : '',
|
kind: typeof current.kind === 'string' ? current.kind : '',
|
||||||
status: num(a.status) ?? -1,
|
status: num(a.status) ?? -1,
|
||||||
attemptIndex: num(a.attempt_index) ?? 0,
|
attemptIndex: num(a.attempt_index) ?? 0,
|
||||||
@@ -259,9 +272,43 @@ export function parseCapitalAccount(raw: unknown): VestCapitalAccount | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The capital account status every listed live account showed (2026-10-26).
|
/** Vest's capital account statuses, as the Vest app names them (its bundle,
|
||||||
* Anything else is treated as unable to trade until seen and named. */
|
* 2026-10-29). Only an ACTIVE account trades. A passed evaluation sits at
|
||||||
export const VEST_STATUS_ACTIVE = 2;
|
* GOAL_PASSED until its funded account is activated, and is CLAIMED after. */
|
||||||
|
export const VEST_STATUS = {
|
||||||
|
CREATED: 1,
|
||||||
|
ACTIVE: 2,
|
||||||
|
GOAL_PASSED: 3,
|
||||||
|
FAILED_MAX_DRAWDOWN: 4,
|
||||||
|
FAILED_MAX_DAILY_LOSS: 5,
|
||||||
|
CLAIMED: 6,
|
||||||
|
EXPIRED: 7,
|
||||||
|
} as const;
|
||||||
|
export const VEST_STATUS_ACTIVE = VEST_STATUS.ACTIVE;
|
||||||
|
|
||||||
|
/** Vest's stage field: an evaluation's, then the funded account's. */
|
||||||
|
const VEST_STAGE_EVAL = 1;
|
||||||
|
const VEST_STAGE_FUNDED = 2;
|
||||||
|
/** Vest's account_type of an evaluation. */
|
||||||
|
const VEST_ACCOUNT_TYPE_EVAL = 2;
|
||||||
|
|
||||||
|
/** A status as the log says it: Vest's own name, with its number. */
|
||||||
|
export function vestStatusName(status: number): string {
|
||||||
|
const name = Object.entries(VEST_STATUS).find(([, v]) => v === status)?.[0];
|
||||||
|
return `${name ? name.toLowerCase().replace(/_/g, ' ') : 'unknown'} (status ${status})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A passed evaluation whose funded account Vest activates on request (POST
|
||||||
|
* /v3/capital/activate-funded) — the Vest app's own test for its "Go to
|
||||||
|
* funded account" button: an evaluation at GOAL_PASSED, not blocked, on its
|
||||||
|
* plan's last step. An earlier step of a 2-Step passes on to the next step
|
||||||
|
* by itself.
|
||||||
|
*/
|
||||||
|
export function awaitingActivation(a: VestCapitalAccount): boolean {
|
||||||
|
const evaluation = a.accountType === VEST_ACCOUNT_TYPE_EVAL || a.stage === VEST_STAGE_EVAL;
|
||||||
|
return evaluation && a.status === VEST_STATUS.GOAL_PASSED && !a.blocked && a.stepsTotal > 0 && a.step === a.stepsTotal;
|
||||||
|
}
|
||||||
|
|
||||||
const pct = (fraction: number) => Math.round(fraction * 1000) / 10;
|
const pct = (fraction: number) => Math.round(fraction * 1000) / 10;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs"
|
"check": "node --experimental-strip-types --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test scripts/check-trading-logic.mjs scripts/check-instruments.mjs scripts/check-account-status.mjs scripts/check-kept-rules.mjs scripts/check-request-user-agent.mjs scripts/check-vest.mjs scripts/check-vest-client.mjs scripts/check-trades.mjs scripts/check-catalog.mjs scripts/check-extension-vest.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@microsoft/signalr": "^10.0.11",
|
"@microsoft/signalr": "^10.0.11",
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// Checks for the extension's Vest relay: the hook in the Vest tab
|
||||||
|
// (extension/vest-relay.js) and the choice of which tab's token the background
|
||||||
|
// relays (extension/background.js pageVestSession / pickVestSession).
|
||||||
|
//
|
||||||
|
// npm run check
|
||||||
|
//
|
||||||
|
// The hook runs in a sandbox standing in for the Vest page — its own XHR and
|
||||||
|
// fetch, answered here; the background's functions are lifted out of its
|
||||||
|
// source, since it is a service worker and not a module. Nothing reaches Vest
|
||||||
|
// or Chrome. The answers are shaped like the Vest app's own (index bundle,
|
||||||
|
// 2026-10-29): a renewal's carries refreshTokenId, a sign-in's the login's
|
||||||
|
// refreshToken too, an account's renewal a refreshToken in its request.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
|
||||||
|
const read = (path) => readFileSync(new URL(`../${path}`, import.meta.url), 'utf8');
|
||||||
|
const HOOK = read('extension/vest-relay.js');
|
||||||
|
const BACKGROUND = read('extension/background.js');
|
||||||
|
|
||||||
|
const GATEWAY = 'https://api-gateway.hz.vestmarkets.com';
|
||||||
|
const WALLET = '0x7ce10771819f80130e204ddb5ca1522e48a9b451';
|
||||||
|
const answer = (extra) => JSON.stringify({ accessToken: 'eyJ.login.sig', accessExpiresAtMs: 1_800_000_000_000, primaryAddr: WALLET, userId: 'u', ...extra });
|
||||||
|
|
||||||
|
/** A Vest page with the hook run in it (after `before`, a hook already
|
||||||
|
* there): `xhr(url, body, status, text)` and `fetch(...)` play one of the
|
||||||
|
* app's requests through it. */
|
||||||
|
function page(before = {}) {
|
||||||
|
class FakeXHR {
|
||||||
|
constructor() { this.listeners = []; this.responseType = ''; }
|
||||||
|
open(method, url) { this.url = url; }
|
||||||
|
send() {}
|
||||||
|
addEventListener(type, fn) { if (type === 'loadend') this.listeners.push(fn); }
|
||||||
|
}
|
||||||
|
const answers = [];
|
||||||
|
const sandbox = {
|
||||||
|
XMLHttpRequest: FakeXHR, URL,
|
||||||
|
fetch: async () => answers.shift(),
|
||||||
|
...before,
|
||||||
|
};
|
||||||
|
sandbox.window = sandbox;
|
||||||
|
vm.createContext(sandbox);
|
||||||
|
vm.runInContext(HOOK, sandbox);
|
||||||
|
return {
|
||||||
|
sandbox,
|
||||||
|
run: () => vm.runInContext(HOOK, sandbox),
|
||||||
|
xhr(url, body, status, text) {
|
||||||
|
const x = new sandbox.XMLHttpRequest();
|
||||||
|
x.open('POST', url);
|
||||||
|
x.send(body);
|
||||||
|
x.status = status;
|
||||||
|
x.responseText = text;
|
||||||
|
for (const fn of x.listeners) fn();
|
||||||
|
},
|
||||||
|
async fetch(url, body, status, text) {
|
||||||
|
answers.push({ status, clone: () => ({ text: async () => text }) });
|
||||||
|
await sandbox.window.fetch(url, { method: 'POST', body });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the hook keeps the login's renewed token with its session id, and an account's never", async () => {
|
||||||
|
const p = page();
|
||||||
|
assert.equal(p.sandbox.__afVestHook, 2);
|
||||||
|
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r1', rememberMe: true }), 200, answer({ refreshTokenId: 'rt-1' }));
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-1');
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.primaryAddr, WALLET);
|
||||||
|
|
||||||
|
// An account's renewal, by its request, then by its answer alone.
|
||||||
|
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ refreshToken: 'acct-rt', requestId: 'r2' }), 200, answer({ accessToken: 'eyJ.acct.sig', refreshTokenId: 'rt-a' }));
|
||||||
|
await p.fetch(`${GATEWAY}/v3/auth/refresh`, undefined, 200, answer({ accessToken: 'eyJ.acct2.sig', refreshToken: 'acct-rt', refreshTokenId: 'rt-b' }));
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login.sig');
|
||||||
|
|
||||||
|
// The login's next renewal, through fetch; a refused one leaves it be.
|
||||||
|
await p.fetch(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r3' }), 200, answer({ accessToken: 'eyJ.login2.sig', refreshTokenId: 'rt-2' }));
|
||||||
|
p.xhr(`${GATEWAY}/v3/auth/refresh`, JSON.stringify({ requestId: 'r4' }), 401, answer({ accessToken: 'eyJ.no.sig', refreshTokenId: 'rt-x' }));
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.login2.sig');
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a sign-in's token is taken, though its answer carries the login's refresh token", () => {
|
||||||
|
const p = page();
|
||||||
|
p.xhr(`${GATEWAY}/v3/auth/register`, JSON.stringify({ primaryAddr: WALLET }), 200, answer({ accessToken: 'eyJ.signin.sig', refreshToken: 'login-rt', refreshTokenId: 'rt-s' }));
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.accessToken, 'eyJ.signin.sig');
|
||||||
|
assert.equal(p.sandbox.__afVestLogin.refreshTokenId, 'rt-s');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the hook installs once, and beside a version-1 hook', () => {
|
||||||
|
const p = page({ __afVestHook: true, __afVestSession: { accessToken: 'eyJ.old.sig', expiresAt: 1 } });
|
||||||
|
assert.equal(p.sandbox.__afVestHook, 2);
|
||||||
|
const send = p.sandbox.XMLHttpRequest.prototype.send;
|
||||||
|
p.run();
|
||||||
|
assert.equal(p.sandbox.XMLHttpRequest.prototype.send, send);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The background's own functions, out of its source.
|
||||||
|
function lift(name) {
|
||||||
|
const m = BACKGROUND.match(new RegExp(`^function ${name}\\([\\s\\S]*?^}`, 'm'));
|
||||||
|
assert.ok(m, `${name} not found in background.js`);
|
||||||
|
return m[0];
|
||||||
|
}
|
||||||
|
const { isCurrentVestSession, pickVestSession } = new Function(`${lift('isCurrentVestSession')}\n${lift('pickVestSession')}\nreturn { isCurrentVestSession, pickVestSession };`)();
|
||||||
|
const pageVestSession = (window, stored) => new Function('window', 'localStorage', `${lift('pageVestSession')}\nreturn pageVestSession();`)(
|
||||||
|
window, { getItem: (key) => (key === 'vest-auth-session' ? stored : null) },
|
||||||
|
);
|
||||||
|
|
||||||
|
const later = (min) => Date.now() + min * 60_000;
|
||||||
|
const session = (token, expiresAt, refreshTokenId) => ({ accessToken: token, expiresAt, primaryAddr: WALLET, userId: 'u', ...(refreshTokenId !== undefined ? { refreshTokenId } : {}) });
|
||||||
|
const record = (refreshTokenId, accessTokenExpiresAt) => JSON.stringify({ refreshTokenId, accessTokenExpiresAt, refreshTokenExpiresAt: later(43200), rememberMe: true });
|
||||||
|
|
||||||
|
test("a tab reports its live token (version 2's, else version 1's) and the app's current session", () => {
|
||||||
|
const expiresAt = later(12);
|
||||||
|
let out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', expiresAt, 'rt-2'), __afVestSession: session('eyJ.v1.sig', expiresAt) }, record('rt-2', expiresAt));
|
||||||
|
assert.equal(out.hooked, true);
|
||||||
|
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||||
|
assert.deepEqual(out.current, { refreshTokenId: 'rt-2', accessTokenExpiresAt: expiresAt });
|
||||||
|
|
||||||
|
// Hooked by version 1 only: its token, and the tab asks for version 2.
|
||||||
|
out = pageVestSession({ __afVestHook: true, __afVestSession: session('eyJ.v1.sig', later(9)) }, record('rt-3', later(14)));
|
||||||
|
assert.equal(out.hooked, false);
|
||||||
|
assert.equal(out.session.accessToken, 'eyJ.v1.sig');
|
||||||
|
|
||||||
|
// A 0.17.0 hook kept an account's renewal too: version 2's login token
|
||||||
|
// wins over it, though it expires sooner.
|
||||||
|
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', later(5), 'rt-1'), __afVestSession: session('eyJ.acct.sig', later(13)) }, null);
|
||||||
|
assert.equal(out.session.accessToken, 'eyJ.v2.sig');
|
||||||
|
assert.equal(out.current, null);
|
||||||
|
|
||||||
|
// Nearly expired, or a record that doesn't read.
|
||||||
|
out = pageVestSession({ __afVestHook: 2, __afVestLogin: session('eyJ.v2.sig', Date.now() + 20_000, 'rt-1') }, '{nope');
|
||||||
|
assert.equal(out.session, null);
|
||||||
|
assert.equal(out.current, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the token relayed is the one the current session holds, whichever tab has it — not the first tab's", () => {
|
||||||
|
// Tab 1 renewed first; tab 2 renewed after it, so the session is tab 2's.
|
||||||
|
const tab1 = { hooked: true, session: session('eyJ.tab1.sig', later(6), 'rt-1'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||||
|
const tab2 = { hooked: true, session: session('eyJ.tab2.sig', later(13), 'rt-2'), current: { refreshTokenId: 'rt-2', accessTokenExpiresAt: later(13) } };
|
||||||
|
assert.deepEqual(pickVestSession([tab1, tab2]), { session: tab2.session, current: true });
|
||||||
|
assert.equal(isCurrentVestSession(tab1.session, tab1.current), false);
|
||||||
|
|
||||||
|
// Tab 1 renews again: now its token is the current one.
|
||||||
|
const rt3 = { refreshTokenId: 'rt-3', accessTokenExpiresAt: later(15) };
|
||||||
|
const tab1b = { ...tab1, session: session('eyJ.tab1b.sig', later(15), 'rt-3'), current: rt3 };
|
||||||
|
assert.equal(pickVestSession([{ ...tab2, current: rt3 }, tab1b]).session.accessToken, 'eyJ.tab1b.sig');
|
||||||
|
|
||||||
|
// Version-1 hooks keep no id: matched by the expiry the app keeps with it.
|
||||||
|
const v1a = { hooked: false, session: session('eyJ.v1a.sig', later(4)), current: { refreshTokenId: 'rt-9', accessTokenExpiresAt: later(11) } };
|
||||||
|
const v1b = { hooked: false, session: session('eyJ.v1b.sig', v1a.current.accessTokenExpiresAt), current: v1a.current };
|
||||||
|
assert.deepEqual(pickVestSession([v1a, v1b]), { session: v1b.session, current: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('with no tab holding the current session the newest is relayed, and said; with no record, unjudged', () => {
|
||||||
|
const current = { refreshTokenId: 'rt-5', accessTokenExpiresAt: later(14) };
|
||||||
|
const a = { hooked: true, session: session('eyJ.a.sig', later(3), 'rt-3'), current };
|
||||||
|
const b = { hooked: true, session: session('eyJ.b.sig', later(8), 'rt-4'), current };
|
||||||
|
assert.deepEqual(pickVestSession([a, b]), { session: b.session, current: false });
|
||||||
|
assert.deepEqual(pickVestSession([{ ...a, current: null }, { ...b, current: null }]), { session: b.session, current: null });
|
||||||
|
// A tab with no token yet still shows the record.
|
||||||
|
assert.equal(pickVestSession([{ hooked: true, session: null, current }, { ...b, current: null }]).current, false);
|
||||||
|
assert.equal(pickVestSession([{ hooked: true, session: null, current }]), null);
|
||||||
|
assert.equal(pickVestSession([]), null);
|
||||||
|
});
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
// unknown, and the candidate board keeping a symbol the close gate holds back;
|
||||||
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
// a sync older than the bot's own fill, an open a restart cut off, an outside
|
||||||
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
|
// trade beside an open of unknown outcome, and the client's day at 8 PM ET;
|
||||||
// the completed cycles a Vest day's claims end, named at each claim.
|
// the completed cycles a Vest day's claims end, named at each claim; a
|
||||||
|
// refused account-token mint, a new account's history read again within the
|
||||||
|
// minute, and exits that wait out a lapse in the session.
|
||||||
//
|
//
|
||||||
// npm run check
|
// npm run check
|
||||||
//
|
//
|
||||||
@@ -46,12 +48,17 @@ after(() => rmSync(directory, { recursive: true, force: true }));
|
|||||||
const { default: axios } = await import('../lib/http-client.ts');
|
const { default: axios } = await import('../lib/http-client.ts');
|
||||||
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
axios.defaults.adapter = (config) => Promise.reject(new Error(`no network in this test: ${config.method} ${config.url}`));
|
||||||
const { VestClient } = await import('../lib/vest-class.ts');
|
const { VestClient } = await import('../lib/vest-class.ts');
|
||||||
|
// The client's own transport, for the checks of what it does with an answer.
|
||||||
|
const realRequest = VestClient.prototype.request;
|
||||||
let gateway = null;
|
let gateway = null;
|
||||||
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
VestClient.prototype.request = async function (bucket, method, path, opts = {}) {
|
||||||
|
// Calls on an account's token reach the gateway here without one, so its
|
||||||
|
// mint is answered here too.
|
||||||
|
if (opts.mint) return { data: { accessToken: `eyJ.${opts.body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 } };
|
||||||
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
if (!gateway) throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||||
return gateway(method, path, opts);
|
return gateway(method, path, opts);
|
||||||
};
|
};
|
||||||
const { putVestSession } = await import('../lib/vest-session.ts');
|
const { putVestSession, vestSessionFor, dropVestSession } = await import('../lib/vest-session.ts');
|
||||||
const {
|
const {
|
||||||
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
|
botOpenedPositions, loadAccountMeta, saveAccountMeta, saveFundTransactions, wipeAccountData, loadClaimedCycles, saveClaimedCycles,
|
||||||
} = await import('../lib/db.ts');
|
} = await import('../lib/db.ts');
|
||||||
@@ -404,6 +411,103 @@ test("the client's Vest day turns with its numbers, and a sync that stalls stops
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A timeout: an ask let through twice leaves the first one waiting forever.
|
||||||
|
test("a passed evaluation doesn't hold the sync, and its funded account is asked for once", { timeout: 10_000 }, async () => {
|
||||||
|
clockAt('2026-10-29T15:31:00Z');
|
||||||
|
// Vest's status for each account (acc-2 passed), and the balance Vest's
|
||||||
|
// active list leaves out — a passed one's.
|
||||||
|
const status = { 'acc-1': 2, 'acc-2': 3 };
|
||||||
|
let missingBalance = 'acc-2';
|
||||||
|
let activation = null;
|
||||||
|
const sent = [];
|
||||||
|
const client = await syncingClient((method, path, opts) => {
|
||||||
|
if (method === 'GET' && path === '/v3/capital/accounts/active') {
|
||||||
|
return { data: { accounts: Object.keys(status).map((id, i) => ({
|
||||||
|
id, status: status[id], account_type: 2, stage: 1, step: 1, steps_total: 1, plan_product_type: 'eval',
|
||||||
|
attempt_index: i + 80, initial_capital: '50000', max_daily_loss_pct: '0.03',
|
||||||
|
stages: [{ ordinal: 1, kind: 'eval', profit_target: '5000' }, { ordinal: 2, kind: 'funded', profit_target: '' }],
|
||||||
|
})) } };
|
||||||
|
}
|
||||||
|
if (method === 'GET' && path === '/v3/accounts') {
|
||||||
|
return { data: { accounts: [
|
||||||
|
{ account_id: 'acc-1', amount: '50000', account_type: 2 }, { account_id: 'acc-2', amount: '55100', account_type: 2 }, { account_id: 'main', amount: '0', account_type: 1 },
|
||||||
|
].filter((b) => b.account_id !== missingBalance) } };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path === '/v3/capital/activate-funded') {
|
||||||
|
sent.push({ account: opts.account, body: opts.body });
|
||||||
|
return activation();
|
||||||
|
}
|
||||||
|
return syncReads(method, path, []) ?? unanswered(method, path);
|
||||||
|
});
|
||||||
|
const funded = { id: 'acc-2-funded', status: 2, account_type: 3, stage: 2, attempt_index: 81, initial_capital: '50000', max_daily_loss_pct: '0.03', stages: [{ ordinal: 1, kind: 'eval' }, { ordinal: 2, kind: 'funded' }] };
|
||||||
|
// The sync an ask starts behind it, run to its end.
|
||||||
|
const settled = async () => {
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
await client.syncRunning;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await client.requestSync();
|
||||||
|
assert.ok(client.lastSyncAt > 0, "the passed account's missing balance didn't make the sync short of good");
|
||||||
|
assert.equal(client.accountList.find((a) => a.id === B).active, false);
|
||||||
|
assert.deepEqual(client.activatable(), [B]);
|
||||||
|
await assert.rejects(client.activateFunded(A), /only a passed evaluation/);
|
||||||
|
|
||||||
|
// While the ask is out, a second caller is refused, and a sync reads
|
||||||
|
// a list from before Vest's answer: it isn't asked again on it.
|
||||||
|
let answer;
|
||||||
|
activation = () => new Promise((resolve) => { answer = resolve; });
|
||||||
|
const asking = client.activateFunded(B);
|
||||||
|
await assert.rejects(client.activateFunded(B), /hasn't been read back/);
|
||||||
|
clockAt('2026-10-29T15:31:30Z');
|
||||||
|
await client.requestSync();
|
||||||
|
assert.deepEqual(client.activatable(), []);
|
||||||
|
answer({ data: { account: funded } });
|
||||||
|
assert.deepEqual(await asking, { funded: 'V50K-F-D3-81' });
|
||||||
|
assert.deepEqual(sent, [{ account: 'acc-2', body: { eval_account_id: 'acc-2' } }]);
|
||||||
|
|
||||||
|
// Answered: never asked again, while Vest's list is slow to move it
|
||||||
|
// or once it's claimed.
|
||||||
|
await settled();
|
||||||
|
clockAt('2026-10-29T15:40:00Z');
|
||||||
|
await client.requestSync();
|
||||||
|
assert.deepEqual(client.activatable(), []);
|
||||||
|
status['acc-2'] = 6;
|
||||||
|
await client.requestSync();
|
||||||
|
assert.deepEqual(client.activatable(), []);
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
|
||||||
|
// acc-1 passes too. A timeout may have gone in: held until a list
|
||||||
|
// read a minute after it. A refusal went nowhere: free at once.
|
||||||
|
status['acc-1'] = 3;
|
||||||
|
missingBalance = 'acc-1';
|
||||||
|
await client.requestSync();
|
||||||
|
assert.deepEqual(client.activatable(), [A]);
|
||||||
|
activation = () => { throw new Error('/v3/capital/activate-funded timed out after 15s'); };
|
||||||
|
await assert.rejects(client.activateFunded(A), /timed out/);
|
||||||
|
await settled();
|
||||||
|
assert.deepEqual(client.activatable(), []);
|
||||||
|
clockAt('2026-10-29T15:40:30Z');
|
||||||
|
await client.requestSync();
|
||||||
|
assert.deepEqual(client.activatable(), []);
|
||||||
|
clockAt('2026-10-29T15:41:10Z');
|
||||||
|
await client.requestSync();
|
||||||
|
assert.deepEqual(client.activatable(), [A]);
|
||||||
|
activation = () => { throw new VestApiError('/v3/capital/activate-funded: funded limit reached (HTTP 400)', 400); };
|
||||||
|
await assert.rejects(client.activateFunded(A), /funded limit/);
|
||||||
|
assert.deepEqual(client.activatable(), [A]);
|
||||||
|
assert.equal(sent.length, 3);
|
||||||
|
|
||||||
|
// A live account's missing balance still holds the sync.
|
||||||
|
status['acc-1'] = 2;
|
||||||
|
const good = client.lastSyncAt;
|
||||||
|
clockAt('2026-10-29T15:45:00Z');
|
||||||
|
await client.requestSync();
|
||||||
|
assert.equal(client.lastSyncAt, good);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('two payouts on one Vest day share its fund row, and still count two toward the stage', async () => {
|
test('two payouts on one Vest day share its fund row, and still count two toward the stage', async () => {
|
||||||
clockAt('2026-10-27T23:00:00Z');
|
clockAt('2026-10-27T23:00:00Z');
|
||||||
const client = await syncingClient(unanswered);
|
const client = await syncingClient(unanswered);
|
||||||
@@ -529,3 +633,139 @@ test('each claim of a Vest day names the completed cycle it ends, the second and
|
|||||||
client.disconnect();
|
client.disconnect();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a refused account-token mint fails only that account: the login's session stays, and the next account's reads go out", async () => {
|
||||||
|
clockAt('2026-10-29T05:41:00Z');
|
||||||
|
const client = testClient(unanswered, [A, B]);
|
||||||
|
await client.syncRunning;
|
||||||
|
// The client's own transport, on a gateway that refuses acc-1's mint as
|
||||||
|
// Vest did all night on 2026-10-28 (401, code 1002).
|
||||||
|
client.request = realRequest;
|
||||||
|
const adapter = axios.defaults.adapter;
|
||||||
|
const sent = [];
|
||||||
|
axios.defaults.adapter = async (config) => {
|
||||||
|
const path = config.url.replace(/^https:\/\/[^/]+/, '');
|
||||||
|
const body = typeof config.data === 'string' ? JSON.parse(config.data) : undefined;
|
||||||
|
sent.push(`${config.method.toUpperCase()} ${path}${body?.accountId ? ` ${body.accountId}` : ''}`);
|
||||||
|
const reply = (status, data) => ({ status, statusText: '', headers: {}, config, data });
|
||||||
|
if (path === '/v3/auth/account-token') {
|
||||||
|
return body.accountId === 'acc-1'
|
||||||
|
? reply(401, { detail: { code: 1002, msg: 'Unauthorized' } })
|
||||||
|
: reply(200, { accessToken: `eyJ.${body.accountId}.sig`, accessExpiresAtMs: Date.now() + 600_000 });
|
||||||
|
}
|
||||||
|
if (path === '/v3/transactions') return reply(200, { transactions: [] });
|
||||||
|
if (path === '/v3/positions') return reply(200, { positions: [] });
|
||||||
|
return reply(404, {});
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { failedAccounts } = await client.fetchDaysTraded([A, B]);
|
||||||
|
assert.equal(failedAccounts, 1);
|
||||||
|
assert.notEqual(vestSessionFor(WALLET), null);
|
||||||
|
assert.equal(client.historyLoadedIds.has(A), false);
|
||||||
|
assert.equal(client.historyLoadedIds.has(B), true);
|
||||||
|
assert.match(client.lastFetchErrors['V50K-F-D3-1'], /code 1002/);
|
||||||
|
assert.ok(sent.includes('POST /v3/auth/account-token acc-2'));
|
||||||
|
assert.ok(sent.includes('GET /v3/transactions'));
|
||||||
|
} finally {
|
||||||
|
axios.defaults.adapter = adapter;
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an active account with no history is read again within a minute, while a whole pass waits out its retry', async () => {
|
||||||
|
clockAt('2026-10-29T05:41:00Z');
|
||||||
|
const C = vestAccountId('acc-3');
|
||||||
|
const client = testClient(unanswered, [A, B, C]);
|
||||||
|
await client.syncRunning;
|
||||||
|
// acc-3 isn't active: it waits for a whole pass.
|
||||||
|
client.capital.set(C, { status: 3 });
|
||||||
|
client.accountList[2].active = false;
|
||||||
|
client.historyLoaded = true;
|
||||||
|
client.historyLoadedIds = new Set([A]);
|
||||||
|
client.lastDaysFetchStartedAt = Date.now() - 120_000;
|
||||||
|
// A whole pass failed: its retry is four minutes off.
|
||||||
|
client.nextDaysFetchRetryAt = Date.now() + 4 * 60_000;
|
||||||
|
const reads = [];
|
||||||
|
client.fetchDaysTraded = async (ids) => {
|
||||||
|
reads.push(ids);
|
||||||
|
return { failedAccounts: ids ? ids.length : 0 };
|
||||||
|
};
|
||||||
|
const settled = () => new Promise((resolve) => setImmediate(resolve));
|
||||||
|
try {
|
||||||
|
client.maybeFetchDaysTraded();
|
||||||
|
await settled();
|
||||||
|
assert.deepEqual(reads, [[B]]);
|
||||||
|
// It failed again: not before the minute is up, and the whole pass's
|
||||||
|
// retry stays where it was.
|
||||||
|
client.maybeFetchDaysTraded();
|
||||||
|
await settled();
|
||||||
|
assert.deepEqual(reads, [[B]]);
|
||||||
|
clockAt('2026-10-29T05:42:01Z');
|
||||||
|
client.maybeFetchDaysTraded();
|
||||||
|
await settled();
|
||||||
|
assert.deepEqual(reads, [[B], [B]]);
|
||||||
|
clockAt('2026-10-29T05:45:01Z');
|
||||||
|
client.maybeFetchDaysTraded();
|
||||||
|
await settled();
|
||||||
|
assert.deepEqual(reads, [[B], [B], undefined]);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a pass reads the accounts with no history first and the inactive last, and an inactive one's failure isn't the pass's", async () => {
|
||||||
|
clockAt('2026-10-29T05:41:00Z');
|
||||||
|
const C = vestAccountId('acc-3');
|
||||||
|
const client = testClient(unanswered, [A, B, C]);
|
||||||
|
await client.syncRunning;
|
||||||
|
client.capital.set(C, { status: 3 });
|
||||||
|
client.accountList[2].active = false;
|
||||||
|
client.historyLoadedIds = new Set([A]);
|
||||||
|
const order = [];
|
||||||
|
client.fetchTxns = async (uuid) => {
|
||||||
|
order.push(uuid);
|
||||||
|
if (uuid === 'acc-3') throw new Error('/v3/transactions not sent — no account token: Unauthorized (HTTP 401, code 1002)');
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
client.fetchTrades = async () => {};
|
||||||
|
try {
|
||||||
|
const { failedAccounts } = await client.fetchDaysTraded([A, B, C]);
|
||||||
|
assert.deepEqual(order, ['acc-2', 'acc-1', 'acc-3']);
|
||||||
|
assert.equal(failedAccounts, 0);
|
||||||
|
assert.match(client.lastFetchErrors['V50K-F-D3-3'], /code 1002/);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exits wait for the login session to be relayed again; an open goes now or not at all', async () => {
|
||||||
|
clockAt('2026-10-29T06:00:00Z');
|
||||||
|
const legs = [];
|
||||||
|
const client = testClient((method, path, opts) => {
|
||||||
|
if (method === 'GET' && path === '/v3/positions/opened') return { data: { positions: [rawPosition('9101', 'acc-1', iso('2026-10-29T05:59:00Z'))] } };
|
||||||
|
if (method === 'POST' && (path === '/v3/positions/stop-loss' || path === '/v3/positions/take-profit')) {
|
||||||
|
legs.push(`${path.slice('/v3/positions/'.length)} @ ${opts.body.triggerPrice}`);
|
||||||
|
return { data: {} };
|
||||||
|
}
|
||||||
|
throw new Error(`no gateway in this test: ${method} ${path}`);
|
||||||
|
}, [A, B]);
|
||||||
|
try {
|
||||||
|
// The session lapses (Vest refused it) just after an entry filled.
|
||||||
|
dropVestSession(WALLET, vestSessionFor(WALLET).accessToken);
|
||||||
|
assert.equal(vestSessionFor(WALLET), null);
|
||||||
|
const opening = await client.sendOrder(B, NDX, 500, 'Buy', 'Market', undefined, 'open').catch((err) => err);
|
||||||
|
assert.equal(isOrderNotPlaced(opening), true);
|
||||||
|
assert.match(opening.message, /no Vest session/);
|
||||||
|
|
||||||
|
const exits = client.placeOcoExit(A, NDX, 500, 'Sell', 20100, 19900);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1_200));
|
||||||
|
assert.deepEqual(legs, []);
|
||||||
|
// The extension relays it again: the exits go, the stop first.
|
||||||
|
tokens += 1;
|
||||||
|
assert.equal(putVestSession({ accessToken: `eyJ.test${tokens}.sig`, expiresAt: Date.now() + 3_600_000, primaryAddr: WALLET, userId: 'u' }), null);
|
||||||
|
await exits;
|
||||||
|
assert.deepEqual(legs, ['stop-loss @ 19900', 'take-profit @ 20100']);
|
||||||
|
} finally {
|
||||||
|
client.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+37
-1
@@ -14,7 +14,8 @@ import {
|
|||||||
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
vestAccountName, parsePosition, awaitingLegs, parseOpenOrder, unrealizedPnl, parseTxn, bookTxns, txnKind, VEST_TXN,
|
||||||
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
hourlyVolatility, marketOpen, MIN_HOURLY_RETURNS, nextVestResetMs, resetSafeStop, parseHistoryPosition, jwtClaimStrings,
|
||||||
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
|
accountScopedToken, VestApiError, VestNotSentError, nothingWentIn, tokenForRequest, claimWithdrawal, settleClaims,
|
||||||
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill,
|
claimFundRows, readClaims, payoutDaysOf, payoutsSince, longClosure, unknownOpenFill, awaitingActivation, vestStatusName,
|
||||||
|
VEST_STATUS,
|
||||||
} from '../lib/vest-helpers.ts';
|
} from '../lib/vest-helpers.ts';
|
||||||
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
import { putVestSession, vestSessionFor, dropVestSession, listVestSessions, refuseVestSession } from '../lib/vest-session.ts';
|
||||||
import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts';
|
import { computeDayPlan, currentTradeDateCT, tradeDateFor, cycleCompletion } from '../lib/trading-logic.ts';
|
||||||
@@ -120,6 +121,41 @@ test('capital accounts parse, and are named for their rules', () => {
|
|||||||
assert.equal(parseCapitalAccount({ nope: true }), null);
|
assert.equal(parseCapitalAccount({ nope: true }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a passed evaluation waits on its funded account, as the Vest app decides', () => {
|
||||||
|
const passed = { ...evalAccount, status: VEST_STATUS.GOAL_PASSED };
|
||||||
|
const a = parseCapitalAccount(passed);
|
||||||
|
assert.equal(a.step, 1);
|
||||||
|
assert.equal(a.stepsTotal, 1);
|
||||||
|
assert.equal(a.accountType, 2);
|
||||||
|
assert.equal(a.blocked, false);
|
||||||
|
assert.equal(awaitingActivation(a), true);
|
||||||
|
|
||||||
|
// Still trading, already claimed, failed or expired: nothing to activate.
|
||||||
|
for (const status of [1, 2, 4, 5, 6, 7]) assert.equal(awaitingActivation(parseCapitalAccount({ ...evalAccount, status })), false, `status ${status}`);
|
||||||
|
// Blocked by Vest.
|
||||||
|
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, blocked_at: 1793287000000 })), false);
|
||||||
|
// A funded account that reached its goal isn't an evaluation — on the
|
||||||
|
// last step's number too, so only the evaluation test can say so.
|
||||||
|
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3, stage: 2, step: 1 })), false);
|
||||||
|
// Either of Vest's marks makes it one.
|
||||||
|
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, account_type: 3 })), true);
|
||||||
|
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, stage: 2, step: 1 })), true);
|
||||||
|
// A 2-Step's first step passes on to its second by itself; the last one waits.
|
||||||
|
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 1, steps_total: 2 })), false);
|
||||||
|
assert.equal(awaitingActivation(parseCapitalAccount({ ...passed, step: 2, steps_total: 2 })), true);
|
||||||
|
|
||||||
|
// Without step or steps_total, the Vest app's own fallbacks.
|
||||||
|
const { step, steps_total, ...bare } = passed;
|
||||||
|
assert.equal(awaitingActivation(parseCapitalAccount(bare)), true);
|
||||||
|
assert.equal(parseCapitalAccount({ ...bare, stage: 2 }).step, 2);
|
||||||
|
assert.equal(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' }).stepsTotal, 0);
|
||||||
|
assert.equal(awaitingActivation(parseCapitalAccount({ ...bare, plan_product_type: 'instant_funded' })), false);
|
||||||
|
|
||||||
|
assert.equal(vestStatusName(3), 'goal passed (status 3)');
|
||||||
|
assert.equal(vestStatusName(6), 'claimed (status 6)');
|
||||||
|
assert.equal(vestStatusName(9), 'unknown (status 9)');
|
||||||
|
});
|
||||||
|
|
||||||
const openPosition = {
|
const openPosition = {
|
||||||
positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2,
|
positionId: 'afe96d7e-f5ac-4b8a-aae8-f5b63b74e508', accountId: '843f60e2-e013-5a0e-8b6c-face84bc5509', accountType: 2,
|
||||||
symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000',
|
symbol: 'NDX-USD-PERP', side: 'short', quantity: '42.6545', openPrice: '29529.250000000', closePrice: '290.250000000',
|
||||||
|
|||||||
Reference in New Issue
Block a user