Restore live trading under quote pressure
Some checks failed
deploy / deploy (push) Failing after 52s
Some checks failed
deploy / deploy (push) Failing after 52s
Proof: npm test (209/209) covers stale command expiry, bounded executor state, bounded strategy quote cache, bounded quote outcome refresh, and resource guardrails. Assumptions: current DB pair config and armed state remain the operator-approved live trading controls; stale quote commands are unsafe to submit after their min_deadline_ms. Still fake: quote outcomes still infer fills from inventory deltas rather than a venue-native terminal fill event.
This commit is contained in:
parent
82017dd301
commit
92aa636dc0
16 changed files with 503 additions and 26 deletions
|
|
@ -547,6 +547,9 @@ spec:
|
||||||
image: ghcr.io/example/unrip:bootstrap
|
image: ghcr.io/example/unrip:bootstrap
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: IfNotPresent
|
||||||
command: ["node", "src/apps/trade-executor.mjs"]
|
command: ["node", "src/apps/trade-executor.mjs"]
|
||||||
|
env:
|
||||||
|
- name: NODE_OPTIONS
|
||||||
|
value: "--max-old-space-size=896"
|
||||||
ports:
|
ports:
|
||||||
- name: control-api
|
- name: control-api
|
||||||
containerPort: 8087
|
containerPort: 8087
|
||||||
|
|
@ -555,6 +558,11 @@ spec:
|
||||||
name: unrip-config
|
name: unrip-config
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: unrip-secrets
|
name: unrip-secrets
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
memory: 1280Mi
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: executor-state
|
- name: executor-state
|
||||||
mountPath: /var/lib/unrip/executor-state
|
mountPath: /var/lib/unrip/executor-state
|
||||||
|
|
|
||||||
|
|
@ -115,7 +115,12 @@ const intentRequestOutcomeTopics = new Set([
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const topic of topics) {
|
for (const topic of topics) {
|
||||||
await consumer.subscribe({ topic, fromBeginning: true });
|
// Raw quote volume is a live firehose; replaying retained history can starve
|
||||||
|
// durable strategy/execution topics and exhaust the writer.
|
||||||
|
await consumer.subscribe({
|
||||||
|
topic,
|
||||||
|
fromBeginning: topic !== config.kafkaTopicRawNearIntentsQuote,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { createArmedStateStore } from '../core/armed-state-store.mjs';
|
||||||
import { startControlApi } from '../core/control-api.mjs';
|
import { startControlApi } from '../core/control-api.mjs';
|
||||||
import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs';
|
import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs';
|
||||||
import { createLogger, serializeError } from '../core/log.mjs';
|
import { createLogger, serializeError } from '../core/log.mjs';
|
||||||
|
import { createRecentIdCache } from '../core/recent-id-cache.mjs';
|
||||||
import { assertInventorySnapshotEvent, assertMarketPriceEvent, assertNormalizedSwapDemand } from '../core/schemas.mjs';
|
import { assertInventorySnapshotEvent, assertMarketPriceEvent, assertNormalizedSwapDemand } from '../core/schemas.mjs';
|
||||||
import { evaluateTradeOpportunity } from '../core/strategy.mjs';
|
import { evaluateTradeOpportunity } from '../core/strategy.mjs';
|
||||||
import { loadConfig } from '../lib/config.mjs';
|
import { loadConfig } from '../lib/config.mjs';
|
||||||
|
|
@ -51,6 +52,7 @@ const armedStateStore = createArmedStateStore({
|
||||||
fileName: 'strategy-engine-control.json',
|
fileName: 'strategy-engine-control.json',
|
||||||
initialArmed: config.strategyInitialArmed,
|
initialArmed: config.strategyInitialArmed,
|
||||||
});
|
});
|
||||||
|
const seenQuotes = createRecentIdCache({ limit: 5000 });
|
||||||
|
|
||||||
await consumer.subscribe({ topic: config.kafkaTopicNormSwapDemand, fromBeginning: false });
|
await consumer.subscribe({ topic: config.kafkaTopicNormSwapDemand, fromBeginning: false });
|
||||||
await consumer.subscribe({ topic: config.kafkaTopicRefMarketPrice, fromBeginning: false });
|
await consumer.subscribe({ topic: config.kafkaTopicRefMarketPrice, fromBeginning: false });
|
||||||
|
|
@ -66,7 +68,6 @@ const state = {
|
||||||
latest_decision: null,
|
latest_decision: null,
|
||||||
recent_decisions: [],
|
recent_decisions: [],
|
||||||
skipped_counts: {},
|
skipped_counts: {},
|
||||||
seen_quotes: {},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await consumer.run({
|
await consumer.run({
|
||||||
|
|
@ -109,7 +110,7 @@ async function handleDemand(event) {
|
||||||
if (state.paused) return;
|
if (state.paused) return;
|
||||||
const tradingConfig = await tradingConfigStore.getConfig();
|
const tradingConfig = await tradingConfigStore.getConfig();
|
||||||
|
|
||||||
if (state.seen_quotes[event.payload.quote_id]) {
|
if (seenQuotes.has(event.payload.quote_id)) {
|
||||||
const pair = tradingConfig.pairByKey?.get(event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`);
|
const pair = tradingConfig.pairByKey?.get(event.payload.pair || `${event.payload.asset_in}->${event.payload.asset_out}`);
|
||||||
const strategyConfig = pair?.strategyConfig || null;
|
const strategyConfig = pair?.strategyConfig || null;
|
||||||
await publishDecision({
|
await publishDecision({
|
||||||
|
|
@ -131,7 +132,7 @@ async function handleDemand(event) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.seen_quotes[event.payload.quote_id] = true;
|
seenQuotes.add(event.payload.quote_id);
|
||||||
|
|
||||||
const evaluation = evaluateTradeOpportunity({
|
const evaluation = evaluateTradeOpportunity({
|
||||||
demandEvent: event,
|
demandEvent: event,
|
||||||
|
|
@ -197,6 +198,7 @@ const controlApi = startControlApi({
|
||||||
getState() {
|
getState() {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
seen_quotes: seenQuotes.getState(),
|
||||||
trading_config: tradingConfigStore.getState(),
|
trading_config: tradingConfigStore.getState(),
|
||||||
durable_control_state: armedStateStore.getState(),
|
durable_control_state: armedStateStore.getState(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { createConsumer } from '../bus/kafka/consumer.mjs';
|
||||||
import { createProducer } from '../bus/kafka/producer.mjs';
|
import { createProducer } from '../bus/kafka/producer.mjs';
|
||||||
import { createArmedStateStore } from '../core/armed-state-store.mjs';
|
import { createArmedStateStore } from '../core/armed-state-store.mjs';
|
||||||
import { startControlApi } from '../core/control-api.mjs';
|
import { startControlApi } from '../core/control-api.mjs';
|
||||||
|
import { classifyExecuteCommandExpiry } from '../core/executor-command-expiry.mjs';
|
||||||
import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs';
|
import { buildEventEnvelope, parseEventMessage } from '../core/event-envelope.mjs';
|
||||||
import { createExecutorStateStore } from '../core/executor-state-store.mjs';
|
import { createExecutorStateStore } from '../core/executor-state-store.mjs';
|
||||||
import { createIntentRequestController } from '../core/intent-request-controller.mjs';
|
import { createIntentRequestController } from '../core/intent-request-controller.mjs';
|
||||||
|
|
@ -211,6 +212,27 @@ async function handleCommand(event) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expiry = classifyExecuteCommandExpiry(event);
|
||||||
|
if (expiry.expired) {
|
||||||
|
stateStore.markFailed(payload.command_id, {
|
||||||
|
quote_id: payload.quote_id,
|
||||||
|
error: {
|
||||||
|
name: 'StaleExecuteTradeCommand',
|
||||||
|
message: expiry.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await publishResult(payload, {
|
||||||
|
status: 'rejected',
|
||||||
|
result_code: 'stale_execute_command',
|
||||||
|
note: 'execute command deadline elapsed before relay submission',
|
||||||
|
command_age_ms: expiry.age_ms == null ? null : String(expiry.age_ms),
|
||||||
|
command_deadline_ms: expiry.deadline_ms == null ? null : String(expiry.deadline_ms),
|
||||||
|
command_deadline_at: expiry.deadline_at,
|
||||||
|
stale_reason: expiry.reason,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
stateStore.markProcessing(payload.command_id, {
|
stateStore.markProcessing(payload.command_id, {
|
||||||
quote_id: payload.quote_id,
|
quote_id: payload.quote_id,
|
||||||
idempotency_key: payload.idempotency_key,
|
idempotency_key: payload.idempotency_key,
|
||||||
|
|
@ -308,7 +330,7 @@ const controlApi = startControlApi({
|
||||||
trading_config: tradingConfigStore.getState(),
|
trading_config: tradingConfigStore.getState(),
|
||||||
...state,
|
...state,
|
||||||
durable_control_state: armedStateStore.getState(),
|
durable_control_state: armedStateStore.getState(),
|
||||||
durable_state: stateStore.getState(),
|
durable_state: stateStore.getSummary({ limit: 50 }),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
54
src/core/executor-command-expiry.mjs
Normal file
54
src/core/executor-command-expiry.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
const DEFAULT_COMMAND_DEADLINE_MS = 60_000;
|
||||||
|
|
||||||
|
export function classifyExecuteCommandExpiry(event, { now = Date.now() } = {}) {
|
||||||
|
const payload = event?.payload || {};
|
||||||
|
const observedAtMs = parseTimestamp(
|
||||||
|
event?.observed_at
|
||||||
|
|| payload.quote_observed_at
|
||||||
|
|| payload.decision_at
|
||||||
|
|| event?.ingested_at,
|
||||||
|
);
|
||||||
|
const deadlineMs = parseDeadlineMs(payload.min_deadline_ms);
|
||||||
|
|
||||||
|
if (!Number.isFinite(observedAtMs)) {
|
||||||
|
return {
|
||||||
|
expired: true,
|
||||||
|
reason: 'command_timestamp_missing',
|
||||||
|
age_ms: null,
|
||||||
|
deadline_ms: deadlineMs,
|
||||||
|
deadline_at: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(deadlineMs) || deadlineMs <= 0) {
|
||||||
|
return {
|
||||||
|
expired: true,
|
||||||
|
reason: 'command_deadline_invalid',
|
||||||
|
age_ms: Math.max(0, now - observedAtMs),
|
||||||
|
deadline_ms: null,
|
||||||
|
deadline_at: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const deadlineAtMs = observedAtMs + deadlineMs;
|
||||||
|
const ageMs = Math.max(0, now - observedAtMs);
|
||||||
|
return {
|
||||||
|
expired: now > deadlineAtMs,
|
||||||
|
reason: now > deadlineAtMs ? 'command_deadline_elapsed' : null,
|
||||||
|
age_ms: ageMs,
|
||||||
|
deadline_ms: deadlineMs,
|
||||||
|
deadline_at: new Date(deadlineAtMs).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDeadlineMs(value) {
|
||||||
|
if (value == null || value === '') return DEFAULT_COMMAND_DEADLINE_MS;
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTimestamp(value) {
|
||||||
|
if (!value) return Number.NaN;
|
||||||
|
const parsed = Date.parse(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : Number.NaN;
|
||||||
|
}
|
||||||
|
|
@ -2,14 +2,20 @@ import { createJsonStateStore } from './json-state-store.mjs';
|
||||||
|
|
||||||
const INITIAL_STATE = {
|
const INITIAL_STATE = {
|
||||||
commands: {},
|
commands: {},
|
||||||
|
evicted_count: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createExecutorStateStore({ stateDir, fileName = 'trade-executor-commands.json' }) {
|
export function createExecutorStateStore({
|
||||||
|
stateDir,
|
||||||
|
fileName = 'trade-executor-commands.json',
|
||||||
|
maxCommands = 1000,
|
||||||
|
}) {
|
||||||
const store = createJsonStateStore({
|
const store = createJsonStateStore({
|
||||||
stateDir,
|
stateDir,
|
||||||
fileName,
|
fileName,
|
||||||
initialState: INITIAL_STATE,
|
initialState: INITIAL_STATE,
|
||||||
});
|
});
|
||||||
|
compactStore(store, maxCommands);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get(commandId) {
|
get(commandId) {
|
||||||
|
|
@ -21,21 +27,24 @@ export function createExecutorStateStore({ stateDir, fileName = 'trade-executor-
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
markProcessing(commandId, metadata) {
|
markProcessing(commandId, metadata) {
|
||||||
return updateCommand(store, commandId, metadata, 'processing');
|
return updateCommand(store, commandId, metadata, 'processing', maxCommands);
|
||||||
},
|
},
|
||||||
markSubmitted(commandId, metadata) {
|
markSubmitted(commandId, metadata) {
|
||||||
return updateCommand(store, commandId, metadata, 'submitted');
|
return updateCommand(store, commandId, metadata, 'submitted', maxCommands);
|
||||||
},
|
},
|
||||||
markFailed(commandId, metadata) {
|
markFailed(commandId, metadata) {
|
||||||
return updateCommand(store, commandId, metadata, 'failed');
|
return updateCommand(store, commandId, metadata, 'failed', maxCommands);
|
||||||
},
|
},
|
||||||
getState() {
|
getState() {
|
||||||
return normalizeState(store.getState());
|
return normalizeState(store.getState());
|
||||||
},
|
},
|
||||||
|
getSummary({ limit = 50 } = {}) {
|
||||||
|
return summarizeState(store.getState(), { limit });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCommand(store, commandId, metadata, status) {
|
function updateCommand(store, commandId, metadata, status, maxCommands) {
|
||||||
const nextState = store.update((state) => {
|
const nextState = store.update((state) => {
|
||||||
state.commands[commandId] = {
|
state.commands[commandId] = {
|
||||||
...(state.commands[commandId] || {}),
|
...(state.commands[commandId] || {}),
|
||||||
|
|
@ -43,7 +52,7 @@ function updateCommand(store, commandId, metadata, status) {
|
||||||
status,
|
status,
|
||||||
updated_at: new Date().toISOString(),
|
updated_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
return state;
|
return pruneState(state, maxCommands);
|
||||||
});
|
});
|
||||||
|
|
||||||
return nextState.commands[commandId];
|
return nextState.commands[commandId];
|
||||||
|
|
@ -61,5 +70,62 @@ function normalizeState(state) {
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
evicted_count: Number(state.evicted_count || 0),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactStore(store, maxCommands) {
|
||||||
|
const before = store.getState();
|
||||||
|
const beforeCount = Object.keys(before.commands || {}).length;
|
||||||
|
const next = pruneState(before, maxCommands);
|
||||||
|
const afterCount = Object.keys(next.commands || {}).length;
|
||||||
|
if (beforeCount !== afterCount || Number(before.evicted_count || 0) !== Number(next.evicted_count || 0)) {
|
||||||
|
store.setState(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneState(state, maxCommands) {
|
||||||
|
const maxEntries = Math.max(1, Number(maxCommands) || 1000);
|
||||||
|
const entries = Object.entries(state.commands || {}).map(([commandId, command]) => [
|
||||||
|
commandId,
|
||||||
|
normalizeCommand(command),
|
||||||
|
]);
|
||||||
|
entries.sort((left, right) => timestampValue(right[1].updated_at) - timestampValue(left[1].updated_at));
|
||||||
|
const kept = entries.slice(0, maxEntries);
|
||||||
|
const evicted = Math.max(0, entries.length - kept.length);
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
evicted_count: Number(state.evicted_count || 0) + evicted,
|
||||||
|
commands: Object.fromEntries(kept),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeState(state, { limit = 50 } = {}) {
|
||||||
|
const normalized = normalizeState(state);
|
||||||
|
const entries = Object.entries(normalized.commands || {});
|
||||||
|
entries.sort((left, right) => timestampValue(right[1].updated_at) - timestampValue(left[1].updated_at));
|
||||||
|
const byStatus = {};
|
||||||
|
for (const [, command] of entries) {
|
||||||
|
const status = command.status || 'unknown';
|
||||||
|
byStatus[status] = (byStatus[status] || 0) + 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
total_commands: entries.length,
|
||||||
|
evicted_count: normalized.evicted_count,
|
||||||
|
by_status: byStatus,
|
||||||
|
latest_updated_at: entries[0]?.[1]?.updated_at || null,
|
||||||
|
commands: Object.fromEntries(entries.slice(0, Math.max(0, Number(limit) || 50))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCommand(command) {
|
||||||
|
return {
|
||||||
|
...command,
|
||||||
|
status: command.status === 'completed' ? 'submitted' : command.status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestampValue(value) {
|
||||||
|
const parsed = Date.parse(value || '');
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
|
||||||
30
src/core/recent-id-cache.mjs
Normal file
30
src/core/recent-id-cache.mjs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
export function createRecentIdCache({ limit = 5000 } = {}) {
|
||||||
|
const maxEntries = Math.max(1, Number(limit) || 5000);
|
||||||
|
const ids = new Set();
|
||||||
|
const order = [];
|
||||||
|
let evictedCount = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
has(id) {
|
||||||
|
return ids.has(id);
|
||||||
|
},
|
||||||
|
add(id) {
|
||||||
|
if (!id) return this.getState();
|
||||||
|
if (ids.has(id)) return this.getState();
|
||||||
|
ids.add(id);
|
||||||
|
order.push(id);
|
||||||
|
while (order.length > maxEntries) {
|
||||||
|
const evicted = order.shift();
|
||||||
|
if (evicted && ids.delete(evicted)) evictedCount += 1;
|
||||||
|
}
|
||||||
|
return this.getState();
|
||||||
|
},
|
||||||
|
getState() {
|
||||||
|
return {
|
||||||
|
count: ids.size,
|
||||||
|
limit: maxEntries,
|
||||||
|
evicted_count: evictedCount,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -2173,36 +2173,59 @@ export async function refreshQuoteOutcomes(pool, {
|
||||||
btcAsset = null,
|
btcAsset = null,
|
||||||
eureAsset = null,
|
eureAsset = null,
|
||||||
now = Date.now(),
|
now = Date.now(),
|
||||||
|
submissionLimit = 1000,
|
||||||
|
inventoryLimit = 5000,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!btcAsset?.assetId || !eureAsset?.assetId) return [];
|
if (!btcAsset?.assetId || !eureAsset?.assetId) return [];
|
||||||
|
|
||||||
|
const safeSubmissionLimit = Math.max(1, Number(submissionLimit) || 1000);
|
||||||
|
const safeInventoryLimit = Math.max(1, Number(inventoryLimit) || 5000);
|
||||||
|
const submissionsResult = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
||||||
|
FROM (
|
||||||
|
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
||||||
|
FROM trade_execution_results
|
||||||
|
WHERE payload->>'status' = 'submitted'
|
||||||
|
ORDER BY COALESCE(observed_at, ingested_at) DESC
|
||||||
|
LIMIT $1
|
||||||
|
) recent_submissions
|
||||||
|
ORDER BY COALESCE(observed_at, ingested_at) ASC
|
||||||
|
`,
|
||||||
|
[safeSubmissionLimit],
|
||||||
|
);
|
||||||
|
if (!submissionsResult.rows.length) return [];
|
||||||
|
|
||||||
|
const quoteIds = [...new Set(submissionsResult.rows.map((row) => row.quote_id).filter(Boolean))];
|
||||||
|
if (!quoteIds.length) return [];
|
||||||
|
|
||||||
const [
|
const [
|
||||||
submissionsResult,
|
|
||||||
commandsResult,
|
commandsResult,
|
||||||
decisionsResult,
|
decisionsResult,
|
||||||
inventoryResult,
|
inventoryResult,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
pool.query(`
|
|
||||||
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
|
||||||
FROM trade_execution_results
|
|
||||||
WHERE payload->>'status' = 'submitted'
|
|
||||||
ORDER BY COALESCE(observed_at, ingested_at) ASC
|
|
||||||
`),
|
|
||||||
pool.query(`
|
pool.query(`
|
||||||
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
||||||
FROM execute_trade_commands
|
FROM execute_trade_commands
|
||||||
|
WHERE quote_id = ANY($1::text[])
|
||||||
ORDER BY COALESCE(observed_at, ingested_at) ASC
|
ORDER BY COALESCE(observed_at, ingested_at) ASC
|
||||||
`),
|
`, [quoteIds]),
|
||||||
pool.query(`
|
pool.query(`
|
||||||
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
||||||
FROM trade_decisions
|
FROM trade_decisions
|
||||||
|
WHERE quote_id = ANY($1::text[])
|
||||||
ORDER BY COALESCE(observed_at, ingested_at) ASC
|
ORDER BY COALESCE(observed_at, ingested_at) ASC
|
||||||
`),
|
`, [quoteIds]),
|
||||||
pool.query(`
|
pool.query(`
|
||||||
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
||||||
FROM intent_inventory_snapshots
|
FROM (
|
||||||
|
SELECT event_id, observed_at, ingested_at, quote_id, payload
|
||||||
|
FROM intent_inventory_snapshots
|
||||||
|
ORDER BY COALESCE(observed_at, ingested_at) DESC
|
||||||
|
LIMIT $1
|
||||||
|
) recent_inventory_snapshots
|
||||||
ORDER BY COALESCE(observed_at, ingested_at) ASC
|
ORDER BY COALESCE(observed_at, ingested_at) ASC
|
||||||
`),
|
`, [safeInventoryLimit]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const records = deriveQuoteOutcomeRecords({
|
const records = deriveQuoteOutcomeRecords({
|
||||||
|
|
|
||||||
45
test/executor-command-expiry.test.mjs
Normal file
45
test/executor-command-expiry.test.mjs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { classifyExecuteCommandExpiry } from '../src/core/executor-command-expiry.mjs';
|
||||||
|
|
||||||
|
test('execute command expiry rejects commands older than their quote deadline', () => {
|
||||||
|
const result = classifyExecuteCommandExpiry({
|
||||||
|
observed_at: '2026-05-13T10:00:00.000Z',
|
||||||
|
ingested_at: '2026-05-13T10:00:01.000Z',
|
||||||
|
payload: {
|
||||||
|
min_deadline_ms: '15000',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
now: Date.parse('2026-05-13T10:00:16.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.expired, true);
|
||||||
|
assert.equal(result.reason, 'command_deadline_elapsed');
|
||||||
|
assert.equal(result.deadline_at, '2026-05-13T10:00:15.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('execute command expiry keeps fresh commands eligible for relay submission', () => {
|
||||||
|
const result = classifyExecuteCommandExpiry({
|
||||||
|
observed_at: '2026-05-13T10:00:00.000Z',
|
||||||
|
payload: {
|
||||||
|
min_deadline_ms: '15000',
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
now: Date.parse('2026-05-13T10:00:14.999Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.expired, false);
|
||||||
|
assert.equal(result.reason, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('execute command expiry fails closed when timestamps are missing', () => {
|
||||||
|
const result = classifyExecuteCommandExpiry({
|
||||||
|
payload: {
|
||||||
|
min_deadline_ms: '15000',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.expired, true);
|
||||||
|
assert.equal(result.reason, 'command_timestamp_missing');
|
||||||
|
});
|
||||||
|
|
@ -40,3 +40,37 @@ test('executor state store normalizes legacy completed markers to submitted', ()
|
||||||
assert.equal(store.get('cmd-legacy').status, 'submitted');
|
assert.equal(store.get('cmd-legacy').status, 'submitted');
|
||||||
assert.equal(store.getState().commands['cmd-legacy'].status, 'submitted');
|
assert.equal(store.getState().commands['cmd-legacy'].status, 'submitted');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('executor state store prunes old command records before serving state', () => {
|
||||||
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'unrip-executor-'));
|
||||||
|
const statePath = path.join(stateDir, 'trade-executor-commands.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify({
|
||||||
|
commands: {
|
||||||
|
'cmd-old': {
|
||||||
|
status: 'failed',
|
||||||
|
updated_at: '2026-05-13T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
'cmd-mid': {
|
||||||
|
status: 'submitted',
|
||||||
|
updated_at: '2026-05-13T10:01:00.000Z',
|
||||||
|
},
|
||||||
|
'cmd-new': {
|
||||||
|
status: 'processing',
|
||||||
|
updated_at: '2026-05-13T10:02:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = createExecutorStateStore({ stateDir, maxCommands: 2 });
|
||||||
|
const state = store.getState();
|
||||||
|
assert.deepEqual(Object.keys(state.commands).sort(), ['cmd-mid', 'cmd-new']);
|
||||||
|
assert.equal(state.evicted_count, 1);
|
||||||
|
|
||||||
|
const summary = store.getSummary({ limit: 1 });
|
||||||
|
assert.equal(summary.total_commands, 2);
|
||||||
|
assert.deepEqual(Object.keys(summary.commands), ['cmd-new']);
|
||||||
|
assert.equal(summary.by_status.processing, 1);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
const source = readFileSync(new URL('../src/apps/history-writer.mjs', import.meta.url), 'utf8');
|
const source = readFileSync(new URL('../src/apps/history-writer.mjs', import.meta.url), 'utf8');
|
||||||
|
|
||||||
test('history writer consumes from beginning so first events on newly-created topics are durable', () => {
|
test('history writer replays durable topics but joins the raw quote firehose live', () => {
|
||||||
assert.match(source, /consumer\.subscribe\(\{ topic, fromBeginning: true \}\)/);
|
assert.match(source, /fromBeginning:\s*topic !== config\.kafkaTopicRawNearIntentsQuote/);
|
||||||
assert.doesNotMatch(source, /consumer\.subscribe\(\{ topic, fromBeginning: false \}\)/);
|
assert.match(source, /Raw quote volume is a live firehose/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
143
test/postgres-quote-outcomes-refresh.test.mjs
Normal file
143
test/postgres-quote-outcomes-refresh.test.mjs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { refreshQuoteOutcomes } from '../src/lib/postgres.mjs';
|
||||||
|
|
||||||
|
const btcAsset = {
|
||||||
|
assetId: 'nep141:nbtc.bridge.near',
|
||||||
|
decimals: 8,
|
||||||
|
};
|
||||||
|
const eureAsset = {
|
||||||
|
assetId: 'nep141:eure.omft.near',
|
||||||
|
decimals: 18,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('quote outcome refresh bounds source queries and joins by recent quote ids', async () => {
|
||||||
|
const queries = [];
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params = []) {
|
||||||
|
queries.push({ sql, params });
|
||||||
|
if (sql.includes('FROM trade_execution_results')) {
|
||||||
|
assert.match(sql, /LIMIT \$1/);
|
||||||
|
assert.equal(params[0], 2);
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
eventRow({
|
||||||
|
eventId: 'result-1',
|
||||||
|
quoteId: 'quote-1',
|
||||||
|
at: '2026-05-13T10:00:10.000Z',
|
||||||
|
payload: {
|
||||||
|
status: 'submitted',
|
||||||
|
result_code: 'quote_response_ok',
|
||||||
|
quote_id: 'quote-1',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM execute_trade_commands')) {
|
||||||
|
assert.match(sql, /quote_id = ANY\(\$1::text\[\]\)/);
|
||||||
|
assert.deepEqual(params[0], ['quote-1']);
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
eventRow({
|
||||||
|
eventId: 'cmd-1',
|
||||||
|
quoteId: 'quote-1',
|
||||||
|
at: '2026-05-13T10:00:09.000Z',
|
||||||
|
payload: {
|
||||||
|
command_id: 'cmd-1',
|
||||||
|
decision_id: 'decision-1',
|
||||||
|
quote_id: 'quote-1',
|
||||||
|
min_deadline_ms: '15000',
|
||||||
|
asset_in: eureAsset.assetId,
|
||||||
|
asset_out: btcAsset.assetId,
|
||||||
|
amount_in: '1000000000000000000',
|
||||||
|
quote_output: {
|
||||||
|
amount_out: '1000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM trade_decisions')) {
|
||||||
|
assert.match(sql, /quote_id = ANY\(\$1::text\[\]\)/);
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
eventRow({
|
||||||
|
eventId: 'decision-1',
|
||||||
|
quoteId: 'quote-1',
|
||||||
|
at: '2026-05-13T10:00:08.000Z',
|
||||||
|
payload: {
|
||||||
|
decision_id: 'decision-1',
|
||||||
|
quote_id: 'quote-1',
|
||||||
|
decision: 'actionable',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM intent_inventory_snapshots')) {
|
||||||
|
assert.match(sql, /LIMIT \$1/);
|
||||||
|
assert.equal(params[0], 3);
|
||||||
|
return {
|
||||||
|
rows: [
|
||||||
|
eventRow({
|
||||||
|
eventId: 'inventory-1',
|
||||||
|
at: '2026-05-13T10:00:00.000Z',
|
||||||
|
payload: {
|
||||||
|
inventory_id: 'inventory-1',
|
||||||
|
spendable: {
|
||||||
|
[btcAsset.assetId]: '2000',
|
||||||
|
[eureAsset.assetId]: '1000000000000000000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
eventRow({
|
||||||
|
eventId: 'inventory-2',
|
||||||
|
at: '2026-05-13T10:00:12.000Z',
|
||||||
|
payload: {
|
||||||
|
inventory_id: 'inventory-2',
|
||||||
|
spendable: {
|
||||||
|
[btcAsset.assetId]: '1000',
|
||||||
|
[eureAsset.assetId]: '2000000000000000000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('INSERT INTO quote_outcome_attributions')) {
|
||||||
|
return { rows: [], rowCount: 1 };
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected query: ${sql}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const records = await refreshQuoteOutcomes(pool, {
|
||||||
|
btcAsset,
|
||||||
|
eureAsset,
|
||||||
|
now: Date.parse('2026-05-13T10:00:20.000Z'),
|
||||||
|
submissionLimit: 2,
|
||||||
|
inventoryLimit: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(records.length, 1);
|
||||||
|
assert.equal(records[0].quote_id, 'quote-1');
|
||||||
|
assert.equal(queries.filter((entry) => entry.sql.includes('INSERT INTO quote_outcome_attributions')).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function eventRow({
|
||||||
|
eventId,
|
||||||
|
quoteId = null,
|
||||||
|
at,
|
||||||
|
payload,
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
event_id: eventId,
|
||||||
|
observed_at: at,
|
||||||
|
ingested_at: at,
|
||||||
|
quote_id: quoteId,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
22
test/recent-id-cache.test.mjs
Normal file
22
test/recent-id-cache.test.mjs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { createRecentIdCache } from '../src/core/recent-id-cache.mjs';
|
||||||
|
|
||||||
|
test('recent id cache evicts old ids while retaining duplicate checks for recent ids', () => {
|
||||||
|
const cache = createRecentIdCache({ limit: 2 });
|
||||||
|
|
||||||
|
cache.add('quote-1');
|
||||||
|
cache.add('quote-2');
|
||||||
|
assert.equal(cache.has('quote-1'), true);
|
||||||
|
|
||||||
|
cache.add('quote-3');
|
||||||
|
assert.equal(cache.has('quote-1'), false);
|
||||||
|
assert.equal(cache.has('quote-2'), true);
|
||||||
|
assert.equal(cache.has('quote-3'), true);
|
||||||
|
assert.deepEqual(cache.getState(), {
|
||||||
|
count: 2,
|
||||||
|
limit: 2,
|
||||||
|
evicted_count: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
12
test/strategy-engine-static.test.mjs
Normal file
12
test/strategy-engine-static.test.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const source = readFileSync(new URL('../src/apps/strategy-engine.mjs', import.meta.url), 'utf8');
|
||||||
|
|
||||||
|
test('strategy duplicate quote tracking is bounded and state-safe', () => {
|
||||||
|
assert.match(source, /createRecentIdCache\(\{ limit: 5000 \}\)/);
|
||||||
|
assert.match(source, /seenQuotes\.has/);
|
||||||
|
assert.match(source, /seenQuotes\.getState\(\)/);
|
||||||
|
assert.doesNotMatch(source, /seen_quotes:\s*\{\}/);
|
||||||
|
});
|
||||||
|
|
@ -14,3 +14,14 @@ test('own request preflight suppresses maker quote responses to avoid self-match
|
||||||
assert.match(source, /own_request_preflight_in_progress/);
|
assert.match(source, /own_request_preflight_in_progress/);
|
||||||
assert.match(source, /avoid self-matching/);
|
assert.match(source, /avoid self-matching/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('trade executor fails closed on stale execute commands before relay submission', () => {
|
||||||
|
assert.match(source, /classifyExecuteCommandExpiry/);
|
||||||
|
assert.match(source, /stale_execute_command/);
|
||||||
|
assert.match(source, /deadline elapsed before relay submission/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('trade executor exposes summarized durable command state', () => {
|
||||||
|
assert.match(source, /stateStore\.getSummary\(\{ limit: 50 \}\)/);
|
||||||
|
assert.doesNotMatch(source, /durable_state:\s*stateStore\.getState\(\)/);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ function deploymentBlock(name) {
|
||||||
return match[0];
|
return match[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const name of ['near-intents-ingest', 'history-writer', 'operator-dashboard']) {
|
for (const name of ['near-intents-ingest', 'history-writer', 'trade-executor', 'operator-dashboard']) {
|
||||||
test(`${name} has memory guardrails for live quote pressure`, () => {
|
test(`${name} has memory guardrails for live quote pressure`, () => {
|
||||||
const block = deploymentBlock(name);
|
const block = deploymentBlock(name);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue