feat: Binance Futures adapter + futures order types + testnet/mainnet network#95
Conversation
…, testnet/mainnet network PR2 of 3 in the LLM-driven Binance Futures pivot. Lays the futures execution foundation that PR3 will drive from a Claude-generated trade signal. What's reshaped: - OrderRequest/OrderResult/Order: side is now 'long'|'short' (the user-facing position direction; adapter translates internally to BUY/SELL). Add leverage, marginType, takeProfitPrice, stopLossPrice on request, plus entryPrice, liquidationPrice, tpOrderId, slOrderId on result. New OrderStatus, PositionSide, MarginType, Position, SymbolFilter exports. - ExchangeCredentials gains optional `network: 'mainnet'|'testnet'` so the adapter can pick fapi.binance.com vs testnet.binancefuture.com per call. - ExchangeKey schema: `network` column (default 'mainnet') and unique constraint widened to (userId, exchange, network) so a user can hold one key per network. - Order schema: futures fields (leverage, marginType, positionSide, entryPrice, liquidationPrice, takeProfitPrice, stopLossPrice, tpOrderId, slOrderId, realizedPnl, closedAt). Init migration regenerated. What's new: - BinanceRest rewrite against fapi.binance.com (and testnet.binancefuture.com). Reuses the existing HMAC signing path. Implements setLeverage, setMarginType (-4046 idempotent), setPositionMode (-4059 idempotent), getPosition, closePosition (reduceOnly market), placeStopLoss/placeTakeProfit (STOP_MARKET / TAKE_PROFIT_MARKET with closePosition=true and workingType=MARK_PRICE), getSymbolFilter (LOT_SIZE / PRICE_FILTER / MIN_NOTIONAL via cached exchangeInfo). - Worker saga: ConfigureFuturesAccountStep (one-way mode + margin type + leverage, all idempotent) runs before PlaceOrderStep; AttachTpSlStep runs after fill and inline-compensates by force-closing the position if either TP or SL placement fails — a naked position is the worst-case outcome. UpdateDbStep persists futures fields back to the Order row. - Api-server CreateOrderDto picks up leverage (1-20 clamped), marginType, takeProfitPrice, stopLossPrice. mode is locked to 'real' (paper retired in PR1 — testnet replaces it via ExchangeKey.network). - ClaudeToken and LlmDecisionLog Prisma models added now (unused until PR3) so the next migration is purely additive — keeps PR3 small. - Dev-only POST /debug/futures-test endpoint: takes an exchangeKeyId + symbol/side/quantity/leverage/tp/sl, calls the adapter directly (skips Kafka and the full saga), returns entry result + tp/sl orderIds + the resulting Position. Gated by NODE_ENV !== 'production'. Verified: - pnpm build green across all 9 workspace packages - Prisma migrate dev applied cleanly against fresh volume - docker compose dev: postgres/redis/kafka healthy, api-server /api/health 200, worker-service running, web /markets 200 Plan: PR3 will install Claude Code CLI in the worker image, add ClaudeToken storage + /settings/claude UI, build the LLM trade form, and wire the Claude-driven signal flow on top of this saga. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Reviewer's GuideImplements a Binance USDT-M futures execution stack: rewrites the Binance REST adapter to use fapi with network-aware mainnet/testnet routing, extends core order/position types with futures fields, wires a new futures-aware real-order saga (including account configuration and TP/SL attachment with safety compensation), adds API DTOs and exchange key support for futures parameters and networks, introduces debug futures-test endpoint, and updates the database schema for futures orders and upcoming LLM integration models. Sequence diagram for the debug futures-test endpointsequenceDiagram
actor Dev
participant ApiServer as ApiServer_DebugController
participant Prisma as PrismaService
participant Adapter as BinanceRest
participant Binance as BinanceFuturesAPI
Dev->>ApiServer: POST /debug/futures-test (FuturesTestDto)
ApiServer->>ApiServer: Check NODE_ENV !== production
ApiServer->>Prisma: findUnique(exchangeKeyId)
Prisma-->>ApiServer: ExchangeKey (apiKey, secretKey, network)
ApiServer->>ApiServer: decrypt(apiKey, secretKey)
ApiServer->>Adapter: new BinanceRest()
ApiServer->>Adapter: setPositionMode(credentials, false)
Adapter->>Binance: POST /fapi/v1/positionSide/dual
Binance-->>Adapter: 200 or -4059
ApiServer->>Adapter: setMarginType(credentials, symbol, ISOLATED)
Adapter->>Binance: POST /fapi/v1/marginType
Binance-->>Adapter: 200 or -4046
ApiServer->>Adapter: setLeverage(credentials, symbol, leverage)
Adapter->>Binance: POST /fapi/v1/leverage
Binance-->>Adapter: 200
ApiServer->>Adapter: placeOrder(market, side, qty, TP/SL)
Adapter->>Binance: POST /fapi/v1/order
Binance-->>Adapter: Entry order response
Adapter-->>ApiServer: OrderResult entry
alt takeProfitPrice present
ApiServer->>Adapter: placeTakeProfit(credentials, symbol, side, tpPrice)
Adapter->>Binance: POST /fapi/v1/order (TAKE_PROFIT_MARKET)
Binance-->>Adapter: TP order response
Adapter-->>ApiServer: OrderResult tp
end
alt stopLossPrice present
ApiServer->>Adapter: placeStopLoss(credentials, symbol, side, slPrice)
Adapter->>Binance: POST /fapi/v1/order (STOP_MARKET)
Binance-->>Adapter: SL order response
Adapter-->>ApiServer: OrderResult sl
end
ApiServer->>Adapter: getPosition(credentials, symbol)
Adapter->>Binance: GET /fapi/v2/positionRisk
Binance-->>Adapter: Position data
Adapter-->>ApiServer: Position
ApiServer-->>Dev: {network, entry, tpOrderId, slOrderId, position}
Entity relationship diagram for futures and LLM DB changeserDiagram
User {
text id PK
}
ExchangeKey {
text id PK
text userId FK
text exchange
text network
text apiKey
text secretKey
datetime createdAt
datetime updatedAt
unique userId_exchange_network
}
Order {
text id PK
text userId FK
text exchange
text symbol
text side
text type
text status
text quantity
text price
text filledQuantity
text filledPrice
text fee
text feeCurrency
int leverage
text marginType
text positionSide
text entryPrice
text liquidationPrice
text takeProfitPrice
text stopLossPrice
text tpOrderId
text slOrderId
text realizedPnl
datetime closedAt
datetime createdAt
datetime updatedAt
index userId_createdAt
}
ClaudeToken {
text id PK
text userId FK
text encryptedToken
datetime createdAt
datetime updatedAt
unique userId
}
LlmDecisionLog {
text id PK
text userId FK
text orderId FK
text prompt
text rawResponse
jsonb parsedSignal
text model
int latencyMs
datetime createdAt
unique orderId
index userId_createdAt
}
User ||--o{ ExchangeKey : has
User ||--o{ Order : has
User ||--o{ ClaudeToken : has
User ||--o{ LlmDecisionLog : has
Order ||--o| LlmDecisionLog : decision_for
Updated class diagram for futures order types and Binance REST adapterclassDiagram
class ExchangeCredentials {
+string apiKey
+string secretKey
+Network network
}
class Network {
<<enumeration>>
mainnet
testnet
}
class PositionSide {
<<enumeration>>
long
short
}
class MarginType {
<<enumeration>>
ISOLATED
CROSS
}
class OrderStatus {
<<enumeration>>
pending
placed
filled
partial
cancelled
failed
}
class OrderRequest {
+ExchangeId exchange
+string symbol
+PositionSide side
+string type
+string quantity
+string price
+number leverage
+MarginType marginType
+string takeProfitPrice
+string stopLossPrice
}
class OrderResult {
+ExchangeId exchange
+string orderId
+string symbol
+PositionSide side
+string type
+OrderStatus status
+string quantity
+string filledQuantity
+string price
+string filledPrice
+string fee
+string feeCurrency
+number timestamp
+string entryPrice
+string liquidationPrice
+number leverage
+string tpOrderId
+string slOrderId
}
class Position {
+ExchangeId exchange
+string symbol
+PositionSide side
+string quantity
+string entryPrice
+string markPrice
+string liquidationPrice
+number leverage
+MarginType marginType
+string unrealizedPnl
}
class SymbolFilter {
+string symbol
+number pricePrecision
+number quantityPrecision
+string minQty
+string stepSize
+string minNotional
+string tickSize
}
class IExchangeRest {
<<interface>>
+getBalances(credentials, ) Balance[]
+getOpenOrders(credentials, symbol) OrderResult[]
+placeOrder(credentials, order) OrderResult
+cancelOrder(credentials, orderId, symbol) OrderResult
+getOrder(credentials, orderId, symbol) OrderResult
+getMarkets() Market[]
+getCandles(symbol, interval, limit) Candle[]
+getHistoricalCandles(symbol, interval, startTime, endTime) Candle[]
+setLeverage(credentials, symbol, leverage) void
+setMarginType(credentials, symbol, marginType) void
+setPositionMode(credentials, dualSide) void
+getPosition(credentials, symbol) Position
+closePosition(credentials, symbol, side, quantity) OrderResult
+placeStopLoss(credentials, symbol, side, stopPrice) OrderResult
+placeTakeProfit(credentials, symbol, side, stopPrice) OrderResult
+getSymbolFilter(symbol) SymbolFilter
}
class BinanceRest {
+string exchangeId
+getBalances(credentials) Balance[]
+getOpenOrders(credentials, symbol) OrderResult[]
+placeOrder(credentials, order) OrderResult
+cancelOrder(credentials, orderId, symbol) OrderResult
+getOrder(credentials, orderId, symbol) OrderResult
+getMarkets() Market[]
+getCandles(symbol, interval, limit) Candle[]
+getHistoricalCandles(symbol, interval, startTime, endTime) Candle[]
+setLeverage(credentials, symbol, leverage) void
+setMarginType(credentials, symbol, marginType) void
+setPositionMode(credentials, dualSide) void
+getPosition(credentials, symbol) Position
+closePosition(credentials, symbol, side, quantity) OrderResult
+placeStopLoss(credentials, symbol, side, stopPrice) OrderResult
+placeTakeProfit(credentials, symbol, side, stopPrice) OrderResult
+getSymbolFilter(symbol) SymbolFilter
-fetchExchangeInfo(network) ExchangeInfoSymbol
-mapOrderResult(response) OrderResult
-mapOrderStatus(status) OrderStatus
-signedRequest(credentials, method, path, params) Response
}
IExchangeRest <|.. BinanceRest
OrderRequest --> PositionSide
OrderRequest --> MarginType
OrderResult --> OrderStatus
OrderResult --> PositionSide
Position --> PositionSide
Position --> MarginType
IExchangeRest --> ExchangeCredentials
BinanceRest --> ExchangeCredentials
BinanceRest --> Position
BinanceRest --> SymbolFilter
Class diagram for order lifecycle and futures-aware sagasclassDiagram
class CreateOrderDto {
+string exchange
+string symbol
+string side
+string type
+string quantity
+string price
+number leverage
+string marginType
+string takeProfitPrice
+string stopLossPrice
+string mode
+string exchangeKeyId
}
class OrderLifecycleContext {
+string userId
+string exchange
+string symbol
+string side
+string type
+string mode
+string quantity
+string price
+string exchangeKeyId
+number leverage
+string marginType
+string takeProfitPrice
+string stopLossPrice
+string orderId
+string requestId
}
class SagaStep_OrderLifecycle {
<<interface>>
+string name
+execute(context) OrderLifecycleContext
+compensate(context) void
}
class CreateOrderStep {
+string name
+execute(context) OrderLifecycleContext
+compensate(context) void
}
class PublishOrderRequestStep {
+string name
+execute(context) OrderLifecycleContext
+compensate(context) void
}
class RealExecutionContext {
+OrderRequestedEvent event
+ExchangeCredentials credentials
+OrderResult result
+string tpOrderId
+string slOrderId
}
class SagaStep_RealExecution {
<<interface>>
+string name
+execute(context) RealExecutionContext
+compensate(context) void
}
class DecryptKeysStep {
+string name
+execute(context) RealExecutionContext
+compensate(context) void
}
class ConfigureFuturesAccountStep {
+string name
+execute(context) RealExecutionContext
+compensate(context) void
}
class PlaceOrderStep {
+string name
+execute(context) RealExecutionContext
+compensate(context) void
}
class AttachTpSlStep {
+string name
+execute(context) RealExecutionContext
+compensate(context) void
}
class UpdateDbStep {
+string name
+execute(context) RealExecutionContext
+compensate(context) void
}
class PublishResultStep {
+string name
+execute(context) RealExecutionContext
+compensate(context) void
}
class DebugController {
+futuresTest(dto) FuturesTestResponse
}
CreateOrderDto --> OrderLifecycleContext
SagaStep_OrderLifecycle <|.. CreateOrderStep
SagaStep_OrderLifecycle <|.. PublishOrderRequestStep
SagaStep_RealExecution <|.. DecryptKeysStep
SagaStep_RealExecution <|.. ConfigureFuturesAccountStep
SagaStep_RealExecution <|.. PlaceOrderStep
SagaStep_RealExecution <|.. AttachTpSlStep
SagaStep_RealExecution <|.. UpdateDbStep
SagaStep_RealExecution <|.. PublishResultStep
RealExecutionContext --> ExchangeCredentials
RealExecutionContext --> OrderResult
DebugController --> ExchangeCredentials
DebugController --> OrderRequest
DebugController --> OrderResult
DebugController --> Position
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- In
BinanceRest.setMarginType/setPositionModethe idempotent success detection relies on substring checks oferr.message(e.g.'-4046'), which is brittle against message format changes; consider parsing the Binance error payload (e.g. code field from JSON) and branching on that instead. BinanceRest.fetchExchangeInfoand the publicgetMarkets/getCandlespaths always use the mainnet public base URL (or a hardcoded default network), so they ignore theExchangeCredentials.networktestnet/mainnet distinction; if you expect behavior differences between networks, it may be worth threading the network through or exposing a network-aware variant.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `BinanceRest.setMarginType`/`setPositionMode` the idempotent success detection relies on substring checks of `err.message` (e.g. `'-4046'`), which is brittle against message format changes; consider parsing the Binance error payload (e.g. code field from JSON) and branching on that instead.
- `BinanceRest.fetchExchangeInfo` and the public `getMarkets`/`getCandles` paths always use the mainnet public base URL (or a hardcoded default network), so they ignore the `ExchangeCredentials.network` testnet/mainnet distinction; if you expect behavior differences between networks, it may be worth threading the network through or exposing a network-aware variant.
## Individual Comments
### Comment 1
<location path="apps/worker-service/src/orders/sagas/real-execution-steps.ts" line_range="159-168" />
<code_context>
+export class AttachTpSlStep implements SagaStep {
</code_context>
<issue_to_address>
**suggestion (bug_risk):** On TP/SL attach failure you force-close the position but don't cancel any already-created TP/SL order.
If only one of TP/SL is created and the other fails, the catch block force‑closes the position but leaves the successfully created order live. In one‑way mode with `closePosition=true` this is probably invalid after close, but it still leaves a dangling order on the exchange.
Consider tracking which legs were created and cancelling them around the forced close so the account state stays consistent. If you’re deliberately relying on the exchange to noop these, it’d be useful to document that assumption centrally so future changes don’t accidentally violate it.
Suggested implementation:
```typescript
/**
* Attaches STOP_MARKET (SL) and TAKE_PROFIT_MARKET (TP) close-position orders
* after the entry has filled. If either fails, we:
*
* - Cancel any TP/SL legs that were successfully created so far, and then
* - Compensate by force-closing the underlying position so it never sits naked.
*
* This keeps account state consistent and avoids leaving dangling TP/SL orders
* on the exchange after a forced close.
*/
export class AttachTpSlStep implements SagaStep {
readonly name = 'AttachTpSl';
private readonly logger = new Logger(AttachTpSlStep.name);
async execute(context: RealExecutionContext): Promise<RealExecutionContext> {
const { event, credentials, result } = context;
if (!credentials) throw new Error('No credentials available');
if (!result) throw new Error('No entry order result available');
// Track successfully created TP/SL legs so we can cancel them on failure
const createdTpOrderIds: string[] = [];
const createdSlOrderIds: string[] = [];
const order = event.order;
if (!order.takeProfitPrice && !order.stopLossPrice) {
```
The visible snippet only shows the beginning of `AttachTpSlStep.execute`, not the core logic where TP/SL orders are actually created and where the force-close compensation happens. To fully implement the behavior described in the doc comment and my review, you’ll need to:
1. **Track created TP/SL legs at creation time**
- Wherever you currently create the TP order (e.g. `client.createOrder` / `exchange.placeOrder` / similar), capture the returned order ID and push it into `createdTpOrderIds`.
- Example pattern (adapt to your actual client API):
```ts
const tpOrder = await client.createOrder({ ...tpPayload });
createdTpOrderIds.push(tpOrder.id);
```
- Do the same for the SL order, pushing into `createdSlOrderIds`.
2. **Cancel created legs in the error path before or around the forced close**
- In the `try/catch` where you currently handle TP/SL attach failure and perform the forced close:
- In the `catch` block, before (or immediately after) sending the force-close order, cancel any previously created legs.
- Example pattern:
```ts
} catch (err) {
this.logger.error('Failed to attach TP/SL legs', { err });
// Best-effort cancel of any already-attached legs
for (const orderId of [...createdTpOrderIds, ...createdSlOrderIds]) {
try {
await client.cancelOrder(orderId, symbol);
} catch (cancelErr) {
this.logger.warn('Failed to cancel dangling TP/SL leg', {
orderId,
cancelErr,
});
}
}
// Existing force-close compensation
await this.forceClosePosition({ client, symbol, side, ... });
}
```
- Ensure you use the correct symbol / market ID and client instance consistent with the rest of the saga.
3. **Handle partial failures in a single-attach call**
- If TP/SL orders are created in sequence within a single `try` block, the `catch` will naturally see which legs made it into `createdTpOrderIds` / `createdSlOrderIds`.
- If the implementation uses multiple `try/catch` blocks or helper methods, propagate `createdTpOrderIds` / `createdSlOrderIds` into those helpers or return the created order IDs so they can be cancelled from one central error handler.
4. **Preserve idempotency / resilience**
- Make cancellation “best-effort”: log and continue if a cancel fails, rather than failing the entire saga.
- If the exchange client throws a specific “order not found / already closed” error, treat that as a noop to avoid spurious warnings.
Adjust the exact method names (`cancelOrder`, `forceClosePosition`, client acquisition, symbol/side handling) to match the existing conventions in `real-execution-steps.ts` and any shared exchange service you already use.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| export class AttachTpSlStep implements SagaStep { | ||
| readonly name = 'AttachTpSl'; | ||
| private readonly logger = new Logger(AttachTpSlStep.name); | ||
|
|
||
| async execute(context: RealExecutionContext): Promise<RealExecutionContext> { | ||
| const { event, credentials, result } = context; | ||
| if (!credentials) throw new Error('No credentials available'); | ||
| if (!result) throw new Error('No entry order result available'); | ||
|
|
||
| const order = event.order; |
There was a problem hiding this comment.
suggestion (bug_risk): On TP/SL attach failure you force-close the position but don't cancel any already-created TP/SL order.
If only one of TP/SL is created and the other fails, the catch block force‑closes the position but leaves the successfully created order live. In one‑way mode with closePosition=true this is probably invalid after close, but it still leaves a dangling order on the exchange.
Consider tracking which legs were created and cancelling them around the forced close so the account state stays consistent. If you’re deliberately relying on the exchange to noop these, it’d be useful to document that assumption centrally so future changes don’t accidentally violate it.
Suggested implementation:
/**
* Attaches STOP_MARKET (SL) and TAKE_PROFIT_MARKET (TP) close-position orders
* after the entry has filled. If either fails, we:
*
* - Cancel any TP/SL legs that were successfully created so far, and then
* - Compensate by force-closing the underlying position so it never sits naked.
*
* This keeps account state consistent and avoids leaving dangling TP/SL orders
* on the exchange after a forced close.
*/
export class AttachTpSlStep implements SagaStep {
readonly name = 'AttachTpSl';
private readonly logger = new Logger(AttachTpSlStep.name);
async execute(context: RealExecutionContext): Promise<RealExecutionContext> {
const { event, credentials, result } = context;
if (!credentials) throw new Error('No credentials available');
if (!result) throw new Error('No entry order result available');
// Track successfully created TP/SL legs so we can cancel them on failure
const createdTpOrderIds: string[] = [];
const createdSlOrderIds: string[] = [];
const order = event.order;
if (!order.takeProfitPrice && !order.stopLossPrice) {The visible snippet only shows the beginning of AttachTpSlStep.execute, not the core logic where TP/SL orders are actually created and where the force-close compensation happens. To fully implement the behavior described in the doc comment and my review, you’ll need to:
-
Track created TP/SL legs at creation time
- Wherever you currently create the TP order (e.g.
client.createOrder/exchange.placeOrder/ similar), capture the returned order ID and push it intocreatedTpOrderIds.- Example pattern (adapt to your actual client API):
const tpOrder = await client.createOrder({ ...tpPayload }); createdTpOrderIds.push(tpOrder.id);
- Example pattern (adapt to your actual client API):
- Do the same for the SL order, pushing into
createdSlOrderIds.
- Wherever you currently create the TP order (e.g.
-
Cancel created legs in the error path before or around the forced close
- In the
try/catchwhere you currently handle TP/SL attach failure and perform the forced close:- In the
catchblock, before (or immediately after) sending the force-close order, cancel any previously created legs. - Example pattern:
} catch (err) { this.logger.error('Failed to attach TP/SL legs', { err }); // Best-effort cancel of any already-attached legs for (const orderId of [...createdTpOrderIds, ...createdSlOrderIds]) { try { await client.cancelOrder(orderId, symbol); } catch (cancelErr) { this.logger.warn('Failed to cancel dangling TP/SL leg', { orderId, cancelErr, }); } } // Existing force-close compensation await this.forceClosePosition({ client, symbol, side, ... }); }
- In the
- Ensure you use the correct symbol / market ID and client instance consistent with the rest of the saga.
- In the
-
Handle partial failures in a single-attach call
- If TP/SL orders are created in sequence within a single
tryblock, thecatchwill naturally see which legs made it intocreatedTpOrderIds/createdSlOrderIds. - If the implementation uses multiple
try/catchblocks or helper methods, propagatecreatedTpOrderIds/createdSlOrderIdsinto those helpers or return the created order IDs so they can be cancelled from one central error handler.
- If TP/SL orders are created in sequence within a single
-
Preserve idempotency / resilience
- Make cancellation “best-effort”: log and continue if a cancel fails, rather than failing the entire saga.
- If the exchange client throws a specific “order not found / already closed” error, treat that as a noop to avoid spurious warnings.
Adjust the exact method names (cancelOrder, forceClosePosition, client acquisition, symbol/side handling) to match the existing conventions in real-execution-steps.ts and any shared exchange service you already use.
Summary
PR2 of 3 in the LLM-driven Binance Futures pivot. Builds the futures execution foundation that PR3 will drive from a Claude-generated trade signal.
Stacks on top of #94 (PR1). Re-target to `dev` once PR1 merges.
Test plan
Out of scope
🤖 Generated with Claude Code
Summary by Sourcery
Introduce Binance USDT-M futures support with network-aware credentials, futures-specific order semantics, and a safer real-execution pipeline, plus scaffolding for upcoming LLM-driven trading features.
New Features:
Enhancements:
Documentation: