Skip to content

Commit abca49c

Browse files
authored
Merge pull request #61 from usherlabs/archive-fills-transfers
Archive durable fill and transfer execution facts
2 parents f6b5ed7 + 84b591e commit abca49c

22 files changed

Lines changed: 1368 additions & 4 deletions

schema/clickhouse/broker_execution.sql

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,93 @@ ENGINE = MergeTree
6565
PARTITION BY toYYYYMM(parseDateTimeBestEffortOrZero(broker_observed_timestamp))
6666
ORDER BY (exchange, symbol, broker_observed_timestamp);
6767

68+
-- CEX value movements: withdrawals, deposits, and sub<->master internal transfers.
69+
--
70+
-- Column names/types/ORDER BY match the fiet-maker consumer contract
71+
-- (docs/CEX_EXECUTION_ARCHIVE_CONTRACT.md + scripts/sql/clickhouse-schema.sql):
72+
-- MergeTree with a 90-day TTL, DateTime64(3,'UTC') timestamps, string quantities,
73+
-- result_index UInt32, error_summary. Two ADDITIVE columns not in the consumer
74+
-- contract: fee_amount / fee_currency (the ccxt withdrawal object exposes the fee,
75+
-- the dominant small-commit cost). broker_observed_timestamp is emitted as an
76+
-- ISO-8601 UTC string and parsed on insert via the forwarder's
77+
-- date_time_input_format=best_effort (see services/archive-forwarder/index.ts).
78+
--
79+
-- Engine is plain MergeTree (contract), so re-observed rows are NOT collapsed;
80+
-- dedup, when needed, is at read time (GROUP BY / argMax over exchange,
81+
-- account_selector, symbol, external_id, lifecycle_action).
82+
CREATE TABLE IF NOT EXISTS broker_execution.transfer_events
83+
(
84+
broker_observed_timestamp DateTime64(3, 'UTC'),
85+
source LowCardinality(String),
86+
deployment_id LowCardinality(String),
87+
schema_version LowCardinality(String),
88+
account_selector LowCardinality(String),
89+
exchange LowCardinality(String),
90+
symbol LowCardinality(String),
91+
event_kind LowCardinality(String),
92+
lifecycle_action LowCardinality(String),
93+
status LowCardinality(String) DEFAULT '',
94+
asset_symbol LowCardinality(String) DEFAULT '',
95+
amount String DEFAULT '',
96+
address String DEFAULT '',
97+
network LowCardinality(String) DEFAULT '',
98+
external_id String DEFAULT '',
99+
txid String DEFAULT '',
100+
result_index UInt32 DEFAULT 0,
101+
fee_amount String DEFAULT '',
102+
fee_currency LowCardinality(String) DEFAULT '',
103+
exchange_timestamp Nullable(DateTime64(3, 'UTC')),
104+
error_summary String DEFAULT '',
105+
payload_json String DEFAULT ''
106+
)
107+
ENGINE = MergeTree
108+
PARTITION BY toDate(broker_observed_timestamp)
109+
ORDER BY (account_selector, broker_observed_timestamp, exchange, symbol, event_kind, lifecycle_action)
110+
TTL toDateTime(broker_observed_timestamp) + toIntervalDay(90)
111+
SETTINGS ttl_only_drop_parts = 1;
112+
113+
-- Per-fill execution facts from the venue trade-history endpoint (fetchMyTrades),
114+
-- captured by the broker-internal fill poller. GetOrderDetails/createOrder payloads
115+
-- carry no per-trade breakdown and no fee on most venues, so per-fill truth
116+
-- (incl. fee) requires this endpoint; hence event_kind is stamped
117+
-- "trade_history_fill" rather than the contract fixture's "create_order_fill".
118+
--
119+
-- Column names/types/ORDER BY match the fiet-maker consumer contract: MergeTree +
120+
-- 90-day TTL, DateTime64 timestamps, string quantities, fill_index UInt32. Plain
121+
-- MergeTree (contract): the poller re-scans a lookback window after a restart, so
122+
-- the same trade can be re-inserted; dedup is at read time (GROUP BY / argMax over
123+
-- exchange, account_selector, symbol, order_id, fill_id).
124+
CREATE TABLE IF NOT EXISTS broker_execution.fill_events
125+
(
126+
broker_observed_timestamp DateTime64(3, 'UTC'),
127+
source LowCardinality(String),
128+
deployment_id LowCardinality(String),
129+
schema_version LowCardinality(String),
130+
account_selector LowCardinality(String),
131+
exchange LowCardinality(String),
132+
symbol LowCardinality(String),
133+
event_kind LowCardinality(String),
134+
order_id String,
135+
client_order_id String DEFAULT '',
136+
fill_id String DEFAULT '',
137+
fill_index UInt32 DEFAULT 0,
138+
side LowCardinality(String) DEFAULT '',
139+
order_type LowCardinality(String) DEFAULT '',
140+
price String DEFAULT '',
141+
base_quantity String DEFAULT '',
142+
quote_quantity String DEFAULT '',
143+
fee_amount String DEFAULT '',
144+
fee_currency LowCardinality(String) DEFAULT '',
145+
fee_rate String DEFAULT '',
146+
exchange_timestamp Nullable(DateTime64(3, 'UTC')),
147+
payload_json String DEFAULT ''
148+
)
149+
ENGINE = MergeTree
150+
PARTITION BY toDate(broker_observed_timestamp)
151+
ORDER BY (symbol, account_selector, broker_observed_timestamp, exchange, order_id, fill_index)
152+
TTL toDateTime(broker_observed_timestamp) + toIntervalDay(90)
153+
SETTINGS ttl_only_drop_parts = 1;
154+
68155
-- Pre-order top-of-book snapshots captured immediately before an order action,
69156
-- joinable to order_events via market_metadata_hash and the order identifiers.
70157
CREATE TABLE IF NOT EXISTS broker_execution.market_metadata_snapshots

services/archive-forwarder/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ const clickhouse = createClient({
1717
username: config.clickhouse.username,
1818
password: config.clickhouse.password,
1919
database: config.clickhouse.database,
20+
// broker_execution.transfer_events/fill_events use DateTime64 columns; producers
21+
// emit broker_observed_timestamp/exchange_timestamp as ISO-8601 UTC strings, so
22+
// the inserter must best-effort-parse them (basic mode rejects the 'T'/'Z' form).
23+
// Strictly more lenient than basic, so it never breaks the existing String/Int64
24+
// timestamp columns on the other archive tables.
25+
clickhouse_settings: { date_time_input_format: "best_effort" },
2026
});
2127
const inserter = createClickHouseInserter(clickhouse);
2228

@@ -82,11 +88,18 @@ const server = Bun.serve({
8288
}
8389

8490
if (parsed.rejectedRowCount > 0) {
91+
// Name the offending tables: an unknown table (e.g. a forgotten
92+
// SUPPORTED_TABLES entry for a new archive table) would otherwise reject
93+
// the whole batch with only a count, hiding which table is at fault.
94+
console.warn(
95+
`Rejected ${parsed.rejectedRowCount}/${parsed.inputRowCount} archive row(s) from ${parsed.batch.source}; tables: ${parsed.rejectedTables.join(", ")}`,
96+
);
8597
return Response.json(
8698
{
8799
error: "Malformed archive rows in batch",
88100
rejected: parsed.rejectedRowCount,
89101
inputRows: parsed.inputRowCount,
102+
rejectedTables: parsed.rejectedTables,
90103
},
91104
{ status: 400 },
92105
);

services/archive-forwarder/router.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ export type ParsedArchiveBatch =
88
batch: ArchiveBatchRequest;
99
inputRowCount: number;
1010
rejectedRowCount: number;
11+
// Distinct table names among rejected rows (unknown/unsupported tables),
12+
// so the caller can name them in a WARN instead of dropping silently.
13+
// A rejected row with no string `table` contributes "(malformed)".
14+
rejectedTables: string[];
1115
}
1216
| { ok: false };
1317

@@ -45,6 +49,7 @@ export function parseArchiveBatchRequest(body: unknown): ParsedArchiveBatch {
4549
}
4650
const inputRowCount = record.rows.length;
4751
const rows = record.rows.filter(isValidArchiveRow);
52+
const rejectedTables = collectRejectedTables(record.rows);
4853
return {
4954
ok: true,
5055
batch: {
@@ -54,9 +59,22 @@ export function parseArchiveBatchRequest(body: unknown): ParsedArchiveBatch {
5459
},
5560
inputRowCount,
5661
rejectedRowCount: inputRowCount - rows.length,
62+
rejectedTables,
5763
};
5864
}
5965

66+
function collectRejectedTables(rows: unknown[]): string[] {
67+
const tables = new Set<string>();
68+
for (const entry of rows) {
69+
if (isValidArchiveRow(entry)) {
70+
continue;
71+
}
72+
const table = (entry as { table?: unknown } | null)?.table;
73+
tables.add(typeof table === "string" ? table : "(malformed)");
74+
}
75+
return [...tables];
76+
}
77+
6078
/** @deprecated Use parseArchiveBatchRequest returning ParsedArchiveBatch */
6179
export function parseArchiveBatchRequestLegacy(
6280
body: unknown,

services/archive-forwarder/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export const SUPPORTED_TABLES = [
2525
"market_data.cex_trades",
2626
"broker_execution.order_events",
2727
"broker_execution.market_metadata_snapshots",
28+
"broker_execution.transfer_events",
29+
"broker_execution.fill_events",
2830
"strategy_data.policy_evaluation_events",
2931
"strategy_data.strategy_policy_snapshots",
3032
"strategy_data.inventory_settlement_events",

src/handlers/execute-action/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
resolveGrpcError,
1414
stableGrpcErrorCode,
1515
} from "../../helpers/grpc/status";
16+
import type { OrderActivityTracker } from "../../helpers/order-activity-tracker";
1617
import type { OtelMetrics } from "../../helpers/otel";
1718
import { getErrorMessage } from "../../helpers/shared/errors";
1819
import type { PolicyConfig } from "../../types";
@@ -38,6 +39,7 @@ export type ExecuteActionContext = {
3839
verityProverUrl: string;
3940
otelMetrics?: OtelMetrics;
4041
brokerArchiver?: BrokerExecutionArchiver;
42+
orderActivityTracker?: OrderActivityTracker;
4143
};
4244

4345
export function requireSymbol(

src/handlers/execute-action/deposit.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as grpc from "@grpc/grpc-js";
22
import ccxt from "@usherlabs/ccxt";
3+
import { archiveTransferEventInBackground } from "../../helpers/broker-execution-archive";
34
import {
45
depositField,
56
depositMatchesTransaction,
@@ -19,7 +20,13 @@ import type { ExecuteActionContext } from "./context";
1920
import { parsePayloadForAction, rejectWithGrpcError } from "./context";
2021

2122
export async function handleDeposit(ctx: ExecuteActionContext): Promise<void> {
22-
const { normalizedCex, symbol, selectedBrokerAccount, broker } = ctx;
23+
const {
24+
normalizedCex,
25+
symbol,
26+
selectedBrokerAccount,
27+
broker,
28+
brokerArchiver,
29+
} = ctx;
2330

2431
if (!symbol) {
2532
return ctx.wrappedCallback(
@@ -117,6 +124,40 @@ export async function handleDeposit(ctx: ExecuteActionContext): Promise<void> {
117124
const status = normalizeDepositStatus(
118125
depositField(deposit, ["status", "state"]),
119126
);
127+
const depositTxid = String(
128+
depositField(deposit, ["txid", "txId", "tx_hash", "txHash"]) ??
129+
value.transactionHash,
130+
);
131+
const creditedAt = depositField(deposit, [
132+
"creditedAt",
133+
"credited_at",
134+
"updated",
135+
"updatedAt",
136+
"timestamp",
137+
"datetime",
138+
]);
139+
// Observed deposit lifecycle fact (contract lifecycle_action
140+
// "observe_deposit"). MergeTree keeps all observations; dedup, if needed,
141+
// is at read time over (exchange, account, symbol, external_id, status).
142+
archiveTransferEventInBackground(brokerArchiver, {
143+
exchange: normalizedCex,
144+
accountSelector: selectedBrokerAccount?.label,
145+
assetSymbol: symbol,
146+
transfer: {
147+
eventKind: "deposit",
148+
lifecycleAction: "observe_deposit",
149+
status,
150+
amount:
151+
observedAmount !== undefined ? String(observedAmount) : undefined,
152+
address: String(observedAddress ?? value.recipientAddress),
153+
network: depositNetwork?.exchangeNetworkId,
154+
externalId: depositTxid,
155+
txid: depositTxid,
156+
exchangeTimestamp:
157+
typeof creditedAt === "string" ? creditedAt : undefined,
158+
payload: deposit,
159+
},
160+
});
120161
log.info(
121162
`Amount ${value.amount} at ${value.transactionHash} . Paid to ${value.recipientAddress}`,
122163
);

src/handlers/execute-action/handler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { BrokerExecutionArchiver } from "../../helpers/broker-execution-arc
77
import { Action, getActionName, resolveAction } from "../../helpers/constants";
88
import { selectBrokerAccountForCex } from "../../helpers/grpc/broker";
99
import { log } from "../../helpers/logger";
10+
import type { OrderActivityTracker } from "../../helpers/order-activity-tracker";
1011
import type { OtelMetrics } from "../../helpers/otel";
1112
import { safeLogError } from "../../helpers/shared/errors";
1213
import {
@@ -27,6 +28,7 @@ export type ExecuteActionDeps = {
2728
verityProverUrl: string;
2829
otelMetrics?: OtelMetrics;
2930
brokerArchiver?: BrokerExecutionArchiver;
31+
orderActivityTracker?: OrderActivityTracker;
3032
};
3133

3234
export function createExecuteActionHandler(deps: ExecuteActionDeps) {
@@ -38,6 +40,7 @@ export function createExecuteActionHandler(deps: ExecuteActionDeps) {
3840
verityProverUrl,
3941
otelMetrics,
4042
brokerArchiver,
43+
orderActivityTracker,
4144
} = deps;
4245

4346
return async (
@@ -151,6 +154,7 @@ export function createExecuteActionHandler(deps: ExecuteActionDeps) {
151154
verityProverUrl,
152155
otelMetrics,
153156
brokerArchiver,
157+
orderActivityTracker,
154158
};
155159

156160
if (action === Action.Call) {

src/handlers/execute-action/internal-transfer.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ import {
77
transferBinanceInternal,
88
verityHttpClientOverridePredicate,
99
} from "../../helpers";
10+
import {
11+
archiveTransferEventInBackground,
12+
normalizeCcxtTransactionForArchive,
13+
} from "../../helpers/broker-execution-archive";
1014
import { mapCcxtErrorToGrpcStatus } from "../../helpers/grpc/status";
1115
import { log } from "../../helpers/logger";
1216
import { getErrorMessage, safeLogError } from "../../helpers/shared/errors";
@@ -25,6 +29,7 @@ export async function handleInternalTransfer(
2529
verity,
2630
useVerity,
2731
verityProverUrl,
32+
brokerArchiver,
2833
} = ctx;
2934

3035
if (!symbol) {
@@ -103,6 +108,22 @@ export async function handleInternalTransfer(
103108
symbol,
104109
transferPayload.amount,
105110
);
111+
// account_selector is the source; the destination is kept in payload_json.
112+
const normalized = normalizeCcxtTransactionForArchive(result);
113+
archiveTransferEventInBackground(brokerArchiver, {
114+
exchange: normalizedCex,
115+
accountSelector: fromSelector,
116+
assetSymbol: symbol,
117+
transfer: {
118+
eventKind: "internal_transfer",
119+
lifecycleAction: "submit_internal_transfer",
120+
status: normalized.status ?? "ok",
121+
amount: normalized.amount ?? String(transferPayload.amount),
122+
network: "internal",
123+
externalId: normalized.externalId,
124+
payload: { from: fromSelector, to: toSelector, result },
125+
},
126+
});
106127
ctx.wrappedCallback(null, {
107128
proof: verity.proof,
108129
result: JSON.stringify(result),

src/handlers/execute-action/orders.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise<void> {
3636
verityProverUrl,
3737
otelMetrics,
3838
brokerArchiver,
39+
orderActivityTracker,
3940
} = ctx;
4041
const verityProof = verity.proof;
4142

@@ -82,6 +83,14 @@ async function handleCreateOrder(ctx: ExecuteActionContext): Promise<void> {
8283
side: resolution.side,
8384
requestedQuantity: resolution.amountBase ?? orderValue.amount,
8485
};
86+
// Mark this (account, symbol) so the fill poller scans it for trade history.
87+
if (selectedBrokerAccount?.label) {
88+
orderActivityTracker?.record(
89+
cex,
90+
selectedBrokerAccount.label,
91+
resolution.symbol,
92+
);
93+
}
8594
const telemetryIds = extractOrderTelemetryIds(orderValue.params);
8695
const submissionTimestamp = new Date().toISOString();
8796
const marketMetadataHash = await captureMarketMetadataSnapshot(
@@ -183,6 +192,7 @@ async function handleGetOrderDetails(ctx: ExecuteActionContext): Promise<void> {
183192
verityProverUrl,
184193
otelMetrics,
185194
brokerArchiver,
195+
orderActivityTracker,
186196
} = ctx;
187197
const verityProof = verity.proof;
188198

@@ -207,6 +217,9 @@ async function handleGetOrderDetails(ctx: ExecuteActionContext): Promise<void> {
207217
symbol,
208218
{ ...getOrderValue.params },
209219
);
220+
if (selectedBrokerAccount?.label && symbol) {
221+
orderActivityTracker?.record(cex, selectedBrokerAccount.label, symbol);
222+
}
210223
const getOrderContext = {
211224
action: "GetOrderDetails" as const,
212225
cex,
@@ -285,6 +298,7 @@ async function handleCancelOrder(ctx: ExecuteActionContext): Promise<void> {
285298
verityProverUrl,
286299
otelMetrics,
287300
brokerArchiver,
301+
orderActivityTracker,
288302
} = ctx;
289303
const verityProof = verity.proof;
290304

@@ -312,6 +326,9 @@ async function handleCancelOrder(ctx: ExecuteActionContext): Promise<void> {
312326
symbol,
313327
cancelOrderValue.params ?? {},
314328
);
329+
if (selectedBrokerAccount?.label && symbol) {
330+
orderActivityTracker?.record(cex, selectedBrokerAccount.label, symbol);
331+
}
315332
emitOrderExecutionTelemetryInBackground(
316333
otelMetrics,
317334
cancelOrderContext,

0 commit comments

Comments
 (0)