The Live Trading control stored its mode in localStorage, so the shell could claim live money while the bot was in dry-run. Show the Tradingbot status when TRADING_BOT_URL is set, and an unknown state when it is not. Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
298 lines
9.8 KiB
TypeScript
298 lines
9.8 KiB
TypeScript
/** Shared Tradingbot status contract. The browser and the API proxy both use this. */
|
|
|
|
export const TRADING_BOT_STATUS_PATH = "/bot/api/status";
|
|
export const TRADING_BOT_PROXY_PATH = "/api/trading-bot/status";
|
|
export const TRADING_BOT_POLL_INTERVAL_MS = 20_000;
|
|
export const TRADING_BOT_POLL_MAX_BACKOFF_MS = 120_000;
|
|
export const TRADING_BOT_STATUS_TIMEOUT_MS = 5_000;
|
|
export const TRADING_BOT_STATUS_MAX_BYTES = 65_536;
|
|
|
|
const MAX_ORIGIN_LENGTH = 200;
|
|
const MAX_ERROR_LENGTH = 80;
|
|
const MAX_ADAPTER_COUNT = 10_000;
|
|
|
|
const LIVE_TOKENS = new Set(["live", "live_trading", "real", "production"]);
|
|
const PAPER_TOKENS = new Set(["paper", "paper_sim", "paper_trading", "simulation", "sim"]);
|
|
const DRY_RUN_TOKENS = new Set(["dry_run", "dryrun", "dry"]);
|
|
|
|
const MODE_STRING_KEYS = [
|
|
"mode",
|
|
"trading_mode",
|
|
"tradingMode",
|
|
"run_mode",
|
|
"runMode",
|
|
"execution_mode",
|
|
"executionMode",
|
|
"badge",
|
|
"status",
|
|
] as const;
|
|
|
|
const NESTED_KEYS = ["data", "bot", "state", "result", "status"] as const;
|
|
|
|
const ADAPTER_COUNT_KEYS = [
|
|
"adapterCount",
|
|
"adapter_count",
|
|
"adaptersCount",
|
|
"adapters_count",
|
|
"n_adapters",
|
|
] as const;
|
|
|
|
const ERROR_KEYS = [
|
|
"lastError",
|
|
"last_error",
|
|
"last_error_message",
|
|
"errorMessage",
|
|
"error",
|
|
] as const;
|
|
|
|
const EMPTY_ERRORS = new Set(["ok", "none", "null", "undefined", "n/a", "-"]);
|
|
|
|
const UNSAFE_ERROR = /bearer\s|api[_-]?key|password|secret|token|authorization|cookie|https?:\/\//i;
|
|
|
|
export type TradingBotKind = "live" | "paper" | "dry_run";
|
|
|
|
export type TradingBotStatusView = {
|
|
mode: "live" | "paper";
|
|
kind: TradingBotKind;
|
|
adapterCount: number | null;
|
|
lastError: string | null;
|
|
};
|
|
|
|
export type TradingBotOrigin =
|
|
| { kind: "unset" }
|
|
| { kind: "invalid" }
|
|
| { kind: "origin"; origin: string };
|
|
|
|
export type TradingBotSnapshot =
|
|
| { state: "unconfigured" }
|
|
| { state: "offline" }
|
|
| { state: "unknown" }
|
|
| { state: "ready"; status: TradingBotStatusView };
|
|
|
|
/** Accept a bare http(s) origin. Credentials and other schemes are rejected. */
|
|
export function parseTradingBotOrigin(value: string | undefined): TradingBotOrigin {
|
|
const trimmed = value?.trim() ?? "";
|
|
if (!trimmed) return { kind: "unset" };
|
|
if (trimmed.length > MAX_ORIGIN_LENGTH || /\s/.test(trimmed) || hasControlChar(trimmed)) {
|
|
return { kind: "invalid" };
|
|
}
|
|
let url: URL;
|
|
try {
|
|
url = new URL(trimmed);
|
|
} catch {
|
|
return { kind: "invalid" };
|
|
}
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return { kind: "invalid" };
|
|
if (url.username || url.password || !url.hostname) return { kind: "invalid" };
|
|
return { kind: "origin", origin: url.origin };
|
|
}
|
|
|
|
/** Throw when the operator set a value that must not be fetched. Unset stays unset. */
|
|
export function resolveTradingBotOrigin(value: string | undefined): string | undefined {
|
|
const parsed = parseTradingBotOrigin(value);
|
|
if (parsed.kind === "invalid") throw new Error("Invalid TRADING_BOT_URL");
|
|
return parsed.kind === "origin" ? parsed.origin : undefined;
|
|
}
|
|
|
|
export function tradingBotStatusUrl(origin: string): string {
|
|
return `${origin}${TRADING_BOT_STATUS_PATH}`;
|
|
}
|
|
|
|
export function tradingBotBackoffMs(
|
|
intervalMs: number,
|
|
failureCount: number,
|
|
maxBackoffMs: number,
|
|
): number {
|
|
const exponent = Math.min(Math.max(failureCount - 1, 0), 6);
|
|
return Math.min(intervalMs * 2 ** exponent, maxBackoffMs);
|
|
}
|
|
|
|
/** Map a Tradingbot `/bot/api/status` body. Unknown or conflicting modes return null. */
|
|
export function mapTradingBotStatus(body: unknown): TradingBotStatusView | null {
|
|
const record = asRecord(body);
|
|
if (!record) return null;
|
|
const kind = resolveKind(readSignals(record));
|
|
if (!kind) return null;
|
|
return {
|
|
mode: kind === "live" ? "live" : "paper",
|
|
kind,
|
|
adapterCount: readAdapterCount(record),
|
|
lastError: readLastError(record),
|
|
};
|
|
}
|
|
|
|
/** Accept a proxy payload field. Drops unsafe error text instead of dropping the mode. */
|
|
export function coerceTradingBotStatusView(value: unknown): TradingBotStatusView | null {
|
|
const record = asRecord(value);
|
|
if (!record) return null;
|
|
const { mode, kind } = record;
|
|
if (mode !== "live" && mode !== "paper") return null;
|
|
if (kind !== "live" && kind !== "paper" && kind !== "dry_run") return null;
|
|
if ((mode === "live") !== (kind === "live")) return null;
|
|
if (record.adapterCount !== null && parseCount(record.adapterCount) === null) return null;
|
|
if (record.lastError !== null && typeof record.lastError !== "string") return null;
|
|
return {
|
|
mode,
|
|
kind,
|
|
adapterCount: record.adapterCount === null ? null : parseCount(record.adapterCount),
|
|
lastError: record.lastError === null ? null : sanitizeTradingBotError(record.lastError),
|
|
};
|
|
}
|
|
|
|
export function interpretTradingBotProxyResponse(
|
|
status: number,
|
|
body: unknown,
|
|
): TradingBotSnapshot {
|
|
const record = asRecord(body);
|
|
if (status === 200 && record?.configured === false) return { state: "unconfigured" };
|
|
if (record?.configured === true && record.reachable === false) return { state: "offline" };
|
|
if (status === 200 && record?.configured === true && record.reachable === true) {
|
|
if (record.status == null) return { state: "unknown" };
|
|
const view = coerceTradingBotStatusView(record.status);
|
|
return view ? { state: "ready", status: view } : { state: "unknown" };
|
|
}
|
|
return { state: "offline" };
|
|
}
|
|
|
|
/** Keep a short operator-facing error. Drop secrets, URLs, and empty placeholders. */
|
|
export function sanitizeTradingBotError(value: unknown): string | null {
|
|
if (typeof value !== "string") return null;
|
|
const cleaned = stripControlChars(value).replace(/\s+/g, " ").trim();
|
|
if (!cleaned || EMPTY_ERRORS.has(cleaned.toLowerCase())) return null;
|
|
if (UNSAFE_ERROR.test(cleaned)) return null;
|
|
if (cleaned.length <= MAX_ERROR_LENGTH) return cleaned;
|
|
return `${cleaned.slice(0, MAX_ERROR_LENGTH - 1)}…`;
|
|
}
|
|
|
|
function readSignals(record: Record<string, unknown>): TradingBotKind[] {
|
|
const signals: TradingBotKind[] = [];
|
|
pushSignals(record, signals);
|
|
if (signals.length > 0) return signals;
|
|
for (const key of NESTED_KEYS) {
|
|
const nested = asRecord(record[key]);
|
|
if (nested) pushSignals(nested, signals);
|
|
}
|
|
return signals;
|
|
}
|
|
|
|
function pushSignals(record: Record<string, unknown>, signals: TradingBotKind[]): void {
|
|
for (const key of MODE_STRING_KEYS) {
|
|
const value = record[key];
|
|
if (typeof value !== "string") continue;
|
|
const kind = classifyModeToken(value);
|
|
if (kind) signals.push(kind);
|
|
}
|
|
if (record.live === true || record.is_live === true || record.isLive === true) {
|
|
signals.push("live");
|
|
}
|
|
if (record.dry_run === true || record.dryRun === true) signals.push("dry_run");
|
|
if (
|
|
record.paper === true ||
|
|
record.is_paper === true ||
|
|
record.isPaper === true ||
|
|
record.paper_trading === true ||
|
|
record.paperTrading === true
|
|
) {
|
|
signals.push("paper");
|
|
}
|
|
}
|
|
|
|
function resolveKind(signals: TradingBotKind[]): TradingBotKind | null {
|
|
const unique = new Set(signals);
|
|
const live = unique.has("live");
|
|
const paper = unique.has("paper");
|
|
const dryRun = unique.has("dry_run");
|
|
if (live && (paper || dryRun)) return null;
|
|
if (dryRun) return "dry_run";
|
|
if (paper) return "paper";
|
|
if (live) return "live";
|
|
return null;
|
|
}
|
|
|
|
function classifyModeToken(value: string): TradingBotKind | null {
|
|
const token = value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[\s-]+/g, "_");
|
|
if (LIVE_TOKENS.has(token)) return "live";
|
|
if (DRY_RUN_TOKENS.has(token)) return "dry_run";
|
|
if (PAPER_TOKENS.has(token)) return "paper";
|
|
return null;
|
|
}
|
|
|
|
function readAdapterCount(record: Record<string, unknown>): number | null {
|
|
const found = readOptional(record, adapterCountIn);
|
|
return found === undefined ? null : found;
|
|
}
|
|
|
|
function adapterCountIn(record: Record<string, unknown>): number | null | undefined {
|
|
for (const key of ADAPTER_COUNT_KEYS) {
|
|
if (!Object.hasOwn(record, key)) continue;
|
|
return parseCount(record[key]);
|
|
}
|
|
if (Object.hasOwn(record, "adapters") && Array.isArray(record.adapters)) {
|
|
return parseCount(record.adapters.length);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readLastError(record: Record<string, unknown>): string | null {
|
|
const found = readOptional(record, lastErrorIn);
|
|
return found === undefined ? null : found;
|
|
}
|
|
|
|
function lastErrorIn(record: Record<string, unknown>): string | null | undefined {
|
|
for (const key of ERROR_KEYS) {
|
|
if (!Object.hasOwn(record, key)) continue;
|
|
return sanitizeTradingBotError(record[key]);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function readOptional<T>(
|
|
record: Record<string, unknown>,
|
|
read: (row: Record<string, unknown>) => T | undefined,
|
|
): T | undefined {
|
|
const direct = read(record);
|
|
if (direct !== undefined) return direct;
|
|
for (const key of NESTED_KEYS) {
|
|
const nested = asRecord(record[key]);
|
|
if (!nested) continue;
|
|
const value = read(nested);
|
|
if (value !== undefined) return value;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function parseCount(value: unknown): number | null {
|
|
if (typeof value === "string" && !/^\d+$/.test(value.trim())) return null;
|
|
const numeric =
|
|
typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
|
|
if (!Number.isInteger(numeric) || numeric < 0 || numeric > MAX_ADAPTER_COUNT) return null;
|
|
return numeric;
|
|
}
|
|
|
|
function hasControlChar(value: string): boolean {
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const code = value.charCodeAt(index);
|
|
if (code < 32 || code === 127) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function stripControlChars(value: string): string {
|
|
let out = "";
|
|
for (let index = 0; index < value.length; index += 1) {
|
|
const code = value.charCodeAt(index);
|
|
const char = value[index];
|
|
if (char === undefined) continue;
|
|
out += code < 32 || code === 127 ? " " : char;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|