diff --git a/docs/standx/marketmaker.md b/docs/standx/marketmaker.md new file mode 100644 index 0000000..a0df27a --- /dev/null +++ b/docs/standx/marketmaker.md @@ -0,0 +1,525 @@ +Maker Points +StandX is the first perps DEX to award points for users placing limit orders that do not execute as trades — Maker Points reward you for placing limit orders on the orderbook — even if they don’t execute. Orders must remain on the orderbook for over 3 seconds to earn points. This means you can earn points with zero trading risk. If your limit order does not execute, you have no trading risks. + +We use range bands for Maker Points. Users must place maker orders within these bands to earn Maker Points. + +Point Bands (based on distance from mark price): + +0-10 bps: 100% points +10-30 bps: 50% points +30 bps - 100bps: 10% points +These bands are for illustration only. Actual distance thresholds may be adjusted to reflect market conditions. + +Example: + +BTC mark price is $90,000. You post a 1 BTC maker order at $89,950 (within 10 bps). Points per day: $90,000 notional × 100% = 90,000 points. + +Note: + +If your order is partially filled, points continue to accrue on the remaining resting portion. +The mark price updates in real-time and is influenced by each order, which may cause fluctuations. The actual points calculation is based on the system settlement. Place orders slightly tighter than the band threshold to stay within range as price moves. + +用户可以设置挂单总数量,例如设置 1 BTC,则表示所有的挂单数量之和不能超过 1 BTC +可以设置单个挂单数量,例如设置 0.01 BTC,则表示单个挂单数量为 0.01 BTC +用户可以设置最大持仓量,当前仓位达到最大值后,只挂反向订单进行平仓,不允许再扩大仓位 +用户可以设置止损金额,当当前仓位的未实现亏损超过该金额时直接市价平仓 + +挂单范围分为3个档位开关,用户可以设置开启或者关闭 + +0-10 bps +10-30 bps +30-100 bps + +用户可以选择均匀分布或者极值分布,均匀分布会在 1,2,3一直到9 bps 某个档位范围内容的所有档位挂单,极值只在 9 29 99 bps 挂单 + +standx 是一个 perp dex,目前深度并不好,而且盘口价格不一定能有效反馈真实价格,你需要参考标记价格 mark price,当盘口与标记价格不符且超过 1bps 的时候,差值是多少 bps,在这个差值范围内就撤销所有挂单,同时你需要参考币安的合约orderbook,当币安的ask/bid的挂单数量出现明显偏移的时候,例如最近10档的买/卖单比例超过了1:3,则取消所有深度差的一边的挂单,等待比例均衡低于1:3后再重新挂单,币安websocket的文档如下: + +## WebSocket API General Info + +- The base endpoint is: **`wss://ws-fapi.binance.com/ws-fapi/v1`** + - The base endpoint for testnet is: `wss://testnet.binancefuture.com/ws-fapi/v1` +- A single connection to the API is only valid for 24 hours; expect to be disconnected after the 24-hour mark. +- Websocket server will send a ping frame every 3 minutes. + - If the websocket server does not receive a `pong frame` back from the connection within a 10 minute period, the connection will be disconnected. + - When you receive a ping, you must send a pong with a copy of ping's payload as soon as possible. + - Unsolicited pong frames are allowed, but will not prevent disconnection. **It is recommended that the payload for these pong frames are empty.** +- Signature payload must be generated by taking all request params except for the signature and sorting them by name in alphabetical order. +- Lists are returned in **chronological order**, unless noted otherwise. +- All timestamps are in **milliseconds in UTC**, unless noted otherwise. +- All field names and values are **case-sensitive**, unless noted otherwise. +- **`INT` parameters such as timestamp are expected as JSON integers, not strings.** +- **`DECIMAL` parameters such as price are expected as JSON strings, not floats.** +- **User Data Stream requests - you will need to establish a separate WebSocket connection to listen to [user data streams](https://binance-docs.github.io/apidocs/futures/en/#user-data-streams)** + +## WebSocket API Request format + +Requests must be sent as JSON in **text frames**, one request per frame. + +> Example of request: + +```json +{ + "id": "9ca10e58-7452-467e-9454-f669bb9c764e", + "method": "order.place", + "params": { + "apiKey": "yeqKcXjtA9Eu4Tr3nJk61UJAGzXsEmFqqfVterxpMpR4peNfqE7Zl7oans8Qj089", + "price": "42088.0", + "quantity": "0.1", + "recvWindow": 5000, + "side": "BUY", + "signature": "996962a19802b5a09d7bc6ab1524227894533322a2f8a1f8934991689cabf8fe", + "symbol": "BTCUSDT", + "timeInForce": "GTC", + "timestamp": 1705311512994, + "type": "LIMIT" + } +} +``` + +Request fields: + +| Name | Type | Mandatory | Description | +| --- | --- | --- | --- | +| `id` | INT/STRING/null | YES | Arbitrary ID used to match responses to requests | +| `method` | STRING | YES | Request method name | +| `params` | OBJECT | NO | Request parameters. May be omitted if there are no parameters | +| | | | | + +- Request `id` is truly arbitrary. You can use UUIDs, sequential IDs, current timestamp, etc. The server does not interpret `id` in any way, simply echoing it back in the response. + +You can freely reuse IDs within a session. However, be careful to not send more than one request at a time with the same ID, since otherwise it might be impossible to tell the responses apart. + +- Request method names may be prefixed with explicit version: e.g., " `v3/order.place` ". +- The order of `params` is not significant. + +## Response format + +Responses are returned as JSON in text frames, one response per frame. + +> Example of successful response: + +```json +{ + "id": "43a3843a-2321-4e45-8f79-351e5c354563", + "status": 200, + "result": { + "orderId": 336829446, + "symbol": "BTCUSDT", + "status": "NEW", + "clientOrderId": "FqEw6cn0vDhrkmfiwLYPeo", + "price": "42088.00", + "avgPrice": "0.00", + "origQty": "0.100", + "executedQty": "0.000", + "cumQty": "0.000", + "cumQuote": "0.00000", + "timeInForce": "GTC", + "type": "LIMIT", + "reduceOnly": false, + "closePosition": false, + "side": "BUY", + "positionSide": "BOTH", + "stopPrice": "0.00", + "workingType": "CONTRACT_PRICE", + "priceProtect": false, + "origType": "LIMIT", + "priceMatch": "NONE", + "selfTradePreventionMode": "NONE", + "goodTillDate": 0, + "updateTime": 1705385954229 + }, + "rateLimits": [ + { + "rateLimitType": "REQUEST_WEIGHT", + "interval": "MINUTE", + "intervalNum": 1, + "limit": 2400, + "count": 1 + }, + { + "rateLimitType": "ORDERS", + "interval": "SECOND", + "intervalNum": 10, + "limit": 300, + "count": 1 + }, + { + "rateLimitType": "ORDERS", + "interval": "MINUTE", + "intervalNum": 1, + "limit": 1200, + "count": 0 + } + ] +} +``` + +> Example of failed response: + +```json +{ + "id": "5761b939-27b1-4948-ab87-4a372a3f6b72", + "status": 400, + "error": { + "code": -1102, + "msg": "Mandatory parameter 'quantity' was not sent, was empty/null, or malformed." + }, + "rateLimits": [ + { + "rateLimitType": "REQUEST_WEIGHT", + "interval": "MINUTE", + "intervalNum": 1, + "limit": 2400, + "count": 1 + }, + { + "rateLimitType": "ORDERS", + "interval": "SECOND", + "intervalNum": 10, + "limit": 300, + "count": 1 + }, + { + "rateLimitType": "ORDERS", + "interval": "MINUTE", + "intervalNum": 1, + "limit": 1200, + "count": 1 + } + ] +} +``` + +Response fields: + +| Name | Type | Mandatory | Description | +| --- | --- | --- | --- | +| `id` | INT/STRING/null | YES | Same as in the original request | +| `status` | INT | YES | Response status. See status codes | +| `result` | OBJECT/ARRAY | YES | Response content. Present if request succeeded | +| `error` | OBJECT | YES | Error description. Present if request failed | +| `rateLimits` | ARRAY | NO | Rate limiting status. See Rate limits | + +## WebSocket API Rate limits + +- Rate limits are the same as on REST API and are shared with REST API. +- WebSocket handshake attempt costs 5 weight. +- Rate limit for ping/pong frames: maximum 5 per second. +- Rate limit information is included in responses by default, see the `rateLimits` field. +- `rateLimits` field visibility can be controlled with `returnRateLimits` boolean parameter in connection string or individual requests. +- E.g., use `wss://ws-fapi.binance.com/ws-fapi/v1?returnRateLimits=false` to hide `rateLimits` in responses by default. With that, you can pass extra `"returnRateLimits": true` parameter in requests to show rate limit in response when it is otherwise hidden by default. + +## WebSocket API Authenticate after connection + +You can authenticate an already established connection using session authentication requests: + +- `session.logon` - authenticate, or change the API key associated with the connection +- `session.status` - check connection status and the current API key +- `session.logout` - forget the API key associated with the connection + +## WebSocket API API key revocation + +If during an active session the API key becomes invalid for any reason (e.g. IP address is not whitelisted, API key was deleted, API key doesn't have correct permissions, etc), after the next request the session will be revoked with the following error message: + +```javascript +{ + "id": null, + "status": 401, + "error": { + "code": -2015, + "msg": "Invalid API-key, IP, or permissions for action." + } +} +``` + +## WebSocket API Authorize ad hoc requests + +Only one API key can be authenticated with the WebSocket connection. The authenticated API key is used by default for requests that require an apiKey parameter. However, you can always specify the apiKey and signature explicitly for individual requests, overriding the authenticated API key and using a different one to authorize a specific request. + +For example, you might want to authenticate your USER\_DATA key to be used by default, but specify the TRADE key with an explicit signature when placing orders. + +## WebSocket API Authentication request + +**Note**: + +> Only *Ed25519* keys are supported for this feature. + +### Log in with API key (SIGNED) + +> **Request** + +```javascript +{ + "id": "c174a2b1-3f51-4580-b200-8528bd237cb7", + "method": "session.logon", + "params": { + "apiKey": "vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14y7Ju91duEh8A", + "signature": "1cf54395b336b0a9727ef27d5d98987962bc47aca6e13fe978612d0adee066ed", + "timestamp": 1649729878532 + } +} +``` + +> **Response** + +```javascript +{ + "id": "c174a2b1-3f51-4580-b200-8528bd237cb7", + "status": 200, + "result": { + "apiKey": "vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14y7Ju91duEh8A", + "authorizedSince": 1649729878532, + "connectedSince": 1649729873021, + "returnRateLimits": false, + "serverTime": 1649729878630 + } +} +``` + +Authenticate WebSocket connection using the provided API key. + +After calling `session.logon`, you can omit `apiKey` and `signature` parameters for future requests that require them. + +Note that only one API key can be authenticated. Calling `session.logon` multiple times changes the current authenticated API key. + +**Weight:** 2 + +**Method**: "session.logon" + +**Parameters** + +| Name | Type | Mandatory | Description | +| --- | --- | --- | --- | +| `apiKey` | STRING | YES | | +| `recvWindow` | INT | NO | | +| `signature` | STRING | YES | | +| `timestamp` | INT | YES | | + +### Query session status + +> **Request** + +```javascript +{ + "id": "b50c16cd-62c9-4e29-89e4-37f10111f5bf", + "method": "session.status" +} +``` + +> **Response** + +```javascript +{ + "id": "b50c16cd-62c9-4e29-89e4-37f10111f5bf", + "status": 200, + "result": { + // if the connection is not authenticated, "apiKey" and "authorizedSince" will be shown as null + "apiKey": "vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14y7Ju91duEh8A", + "authorizedSince": 1649729878532, + "connectedSince": 1649729873021, + "returnRateLimits": false, + "serverTime": 1649730611671 + } +} +``` + +Query the status of the WebSocket connection, inspecting which API key (if any) is used to authorize requests. + +**Weight:** 2 + +**Method**: "session.status" + +**Parameters**: None + +### Log out of the session + +> **Request** + +```javascript +{ + "id": "c174a2b1-3f51-4580-b200-8528bd237cb7", + "method": "session.logout" +} +``` + +> **Response** + +```javascript +{ + "id": "c174a2b1-3f51-4580-b200-8528bd237cb7", + "status": 200, + "result": { + "apiKey": null, + "authorizedSince": null, + "connectedSince": 1649729873021, + "returnRateLimits": false, + "serverTime": 1649730611671 + } +} +``` + +Forget the API key previously authenticated. If the connection is not authenticated, this request does nothing. + +Note that the WebSocket connection stays open after `session.logout` request. You can continue using the connection, but now you will have to explicitly provide the `apiKey` and `signature` parameters where needed. + +**Weight:** 2 + +**Method**: "session.logout" + +**Parameters**: None + +## SIGNED (TRADE and USER\_DATA) Endpoint Security + +### SIGNED request example (Ed25519) + +| Parameter | Value | +| --- | --- | +| symbol | BTCUSDT | +| side | SELL | +| type | LIMIT | +| timeInForce | GTC | +| quantity | 1 | +| price | 0.2 | +| timestamp | 1668481559918 | + +```python +#!/usr/bin/env python3 + +import base64 +import time +import json +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from websocket import create_connection + +# Set up authentication +API_KEY='put your own API Key here' +PRIVATE_KEY_PATH='test-prv-key.pem' + +# Load the private key. +# In this example the key is expected to be stored without encryption, +# but we recommend using a strong password for improved security. +with open(PRIVATE_KEY_PATH, 'rb') as f: + private_key = load_pem_private_key(data=f.read(), + password=None) + +# Set up the request parameters +params = { + 'apiKey': API_KEY, + 'symbol': 'BTCUSDT', + 'side': 'SELL', + 'type': 'LIMIT', + 'timeInForce': 'GTC', + 'quantity': '1.0000000', + 'price': '0.20' +} + +# Timestamp the request +timestamp = int(time.time() * 1000) # UNIX timestamp in milliseconds +params['timestamp'] = timestamp + +# Sign the request +payload = '&'.join([f'{param}={value}' for param, value in sorted(params.items())]) + +signature = base64.b64encode(private_key.sign(payload.encode('ASCII'))) +params['signature'] = signature.decode('ASCII') + +# Send the request +request = { + 'id': 'my_new_order', + 'method': 'order.place', + 'params': params +} + +ws = create_connection("wss://ws-fapi.binance.com/ws-fapi/v1") +ws.send(json.dumps(request)) +result = ws.recv() +ws.close() + +print(result) +``` + +A sample code in Python to show how to sign the payload with an Ed25519 key is available on the right side. + +## Order Book + +## API Description + +Get current order book. Note that this request returns limited market depth. If you need to continuously monitor order book updates, please consider using Websocket Market Streams: + +- `@depth` +- `@depth` + +You can use `depth` request together with `@depth` streams to maintain a local order book. + +## Method + +`depth` + +**Note**: + +> Retail Price Improvement(RPI) orders are not visible and excluded in the response message. + +## Request + +```javascript +{ + "id": "51e2affb-0aba-4821-ba75-f2625006eb43", + "method": "depth", + "params": { + "symbol": "BTCUSDT" + } +} +``` + +## Request Weight + +Adjusted based on the limit: + +| Limit | Weight | +| --- | --- | +| 5, 10, 20, 50 | 2 | +| 100 | 5 | +| 500 | 10 | +| 1000 | 20 | + +## Request Parameters + +| Name | Type | Mandatory | Description | +| --- | --- | --- | --- | +| symbol | STRING | YES | | +| limit | INT | NO | Default 500; Valid limits:\[5, 10, 20, 50, 100, 500, 1000\] | + +## Response Example + +```javascript +{ + "id": "51e2affb-0aba-4821-ba75-f2625006eb43", + "status": 200, + "result": { + "lastUpdateId": 1027024, + "E": 1589436922972, // Message output time + "T": 1589436922959, // Transaction time + "bids": [ + [ + "4.00000000", // PRICE + "431.00000000" // QTY + ] + ], + "asks": [ + [ + "4.00000200", + "12.00000000" + ] + ] + }, + "rateLimits": [ + { + "rateLimitType": "REQUEST_WEIGHT", + "interval": "MINUTE", + "intervalNum": 1, + "limit": 2400, + "count": 5 + } + ] +} +``` \ No newline at end of file diff --git a/src/cli/args.ts b/src/cli/args.ts index ae90d27..5a1c5d2 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,4 +1,4 @@ -export type StrategyId = "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid"; +export type StrategyId = "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid"; export interface CliOptions { strategy?: StrategyId; @@ -11,6 +11,7 @@ const STRATEGY_VALUES = new Set([ "trend", "guardian", "maker", + "maker-points", "offset-maker", "basis", "grid", @@ -69,6 +70,8 @@ function assignStrategy(options: CliOptions, raw: string): void { options.strategy = normalized as StrategyId; } else if (normalized === "offset" || normalized === "offsetmaker" || normalized === "offset-maker") { options.strategy = "offset-maker"; + } else if (normalized === "makerpoints" || normalized === "maker-points" || normalized === "maker_points") { + options.strategy = "maker-points"; } } @@ -92,7 +95,7 @@ function assignExchange(options: CliOptions, raw: string): void { export function printCliHelp(): void { // eslint-disable-next-line no-console - console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + + console.log(`Usage: bun run index.ts [--strategy ] [--exchange ] [--silent]\n\n` + `Options:\n` + ` --strategy, -s Automatically start the specified strategy without the interactive menu.\n` + ` Aliases: offset, offset-maker for the offset maker engine.\n` + diff --git a/src/cli/strategy-runner.ts b/src/cli/strategy-runner.ts index dced004..6a81d4e 100644 --- a/src/cli/strategy-runner.ts +++ b/src/cli/strategy-runner.ts @@ -1,9 +1,10 @@ -import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, tradingConfig } from "../config"; +import { basisConfig, gridConfig, isBasisStrategyEnabled, makerConfig, makerPointsConfig, tradingConfig } from "../config"; import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; import type { ExchangeAdapter } from "../exchanges/adapter"; import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; import { MakerEngine, type MakerEngineSnapshot } from "../strategy/maker-engine"; import { OffsetMakerEngine, type OffsetMakerEngineSnapshot } from "../strategy/offset-maker-engine"; +import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine"; import { TrendEngine, type TrendEngineSnapshot } from "../strategy/trend-engine"; import { GuardianEngine, type GuardianEngineSnapshot } from "../strategy/guardian-engine"; import { BasisArbEngine, type BasisArbSnapshot } from "../strategy/basis-arb-engine"; @@ -21,6 +22,7 @@ export const STRATEGY_LABELS: Record = { trend: "Trend Following", guardian: "Guardian", maker: "Maker", + "maker-points": "Maker Points", "offset-maker": "Offset Maker", basis: "Basis Arbitrage", grid: "Grid", @@ -74,6 +76,23 @@ const STRATEGY_FACTORIES: Record = { offUpdate: (emitter) => engine.off("update", emitter), }); }, + "maker-points": async (opts) => { + const exchangeId = resolveExchangeId(); + if (exchangeId !== "standx") { + throw new Error("Maker Points strategy only supports the StandX exchange."); + } + const config = makerPointsConfig; + const adapter = createAdapterOrThrow(config.symbol); + const engine = new MakerPointsEngine(config, adapter); + await runEngine({ + engine, + strategy: "maker-points", + silent: opts.silent, + getSnapshot: () => engine.getSnapshot(), + onUpdate: (emitter) => engine.on("update", emitter), + offUpdate: (emitter) => engine.off("update", emitter), + }); + }, "offset-maker": async (opts) => { const config = makerConfig; const adapter = createAdapterOrThrow(config.symbol); @@ -135,6 +154,7 @@ async function runEngine< | TrendEngineSnapshot | GuardianEngineSnapshot | MakerEngineSnapshot + | MakerPointsSnapshot | OffsetMakerEngineSnapshot | BasisArbSnapshot | GridEngineSnapshot diff --git a/src/config.ts b/src/config.ts index a7400a7..db5ec6d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -110,6 +110,41 @@ export const makerConfig: MakerConfig = { priceTick: parseNumber(process.env.MAKER_PRICE_TICK ?? process.env.PRICE_TICK, 0.1), }; +export interface MakerPointsConfig { + symbol: string; + perOrderAmount: number; + closeThreshold: number; + stopLossUsd: number; + refreshIntervalMs: number; + maxLogEntries: number; + maxCloseSlippagePct: number; + priceTick: number; + qtyStep: number; + enableBand0To10: boolean; + enableBand10To30: boolean; + enableBand30To100: boolean; + minRepriceBps: number; +} + +export const makerPointsConfig: MakerPointsConfig = { + symbol: resolveSymbolFromEnv("standx"), + perOrderAmount: parseNumber(process.env.MAKER_POINTS_ORDER_AMOUNT, parseNumber(process.env.TRADE_AMOUNT, 0.001)), + closeThreshold: parseNumber(process.env.MAKER_POINTS_CLOSE_THRESHOLD, 0), + stopLossUsd: parseNumber(process.env.MAKER_POINTS_STOP_LOSS_USD, 0), + refreshIntervalMs: parseNumber(process.env.MAKER_POINTS_REFRESH_INTERVAL_MS, 500), + maxLogEntries: parseNumber(process.env.MAKER_POINTS_MAX_LOG_ENTRIES, 200), + maxCloseSlippagePct: parseNumber( + process.env.MAKER_POINTS_MAX_CLOSE_SLIPPAGE_PCT ?? process.env.MAX_CLOSE_SLIPPAGE_PCT, + 0.05 + ), + priceTick: parseNumber(process.env.MAKER_POINTS_PRICE_TICK ?? process.env.PRICE_TICK, 0.1), + qtyStep: parseNumber(process.env.MAKER_POINTS_QTY_STEP ?? process.env.QTY_STEP, 0.001), + enableBand0To10: parseBoolean(process.env.MAKER_POINTS_BAND_0_10, true), + enableBand10To30: parseBoolean(process.env.MAKER_POINTS_BAND_10_30, true), + enableBand30To100: parseBoolean(process.env.MAKER_POINTS_BAND_30_100, true), + minRepriceBps: parseNumber(process.env.MAKER_POINTS_MIN_REPRICE_BPS, 3), +}; + export interface BasisArbConfig { futuresSymbol: string; spotSymbol: string; diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 0e6b433..bbb46c0 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -35,6 +35,11 @@ const translations: Record = { zh: "双边挂单提供流动性,自动追价与风控止损", en: "Places two-sided quotes, auto-chases and risk-manages stops.", }, + "app.strategy.makerPoints.label": { zh: "StandX 积分做市策略", en: "StandX Maker Points" }, + "app.strategy.makerPoints.desc": { + zh: "基于标记价/盘口挂单赚取 StandX Maker Points", + en: "Quotes by mark-price bands to farm StandX maker points.", + }, "app.strategy.grid.label": { zh: "基础网格策略", en: "Grid Strategy" }, "app.strategy.grid.desc": { zh: "在上下边界之间布设等比网格,自动加仓与减仓", @@ -174,6 +179,27 @@ const translations: Record = { }, "maker.targetOrders": { zh: "目标挂单", en: "Target Orders" }, "maker.noTargetOrders": { zh: "暂无目标挂单", en: "No target orders" }, + "makerPoints.title": { zh: "Maker Points 策略仪表盘", en: "Maker Points Dashboard" }, + "makerPoints.initializing": { zh: "正在初始化 Maker Points 策略…", en: "Initializing Maker Points strategy..." }, + "makerPoints.headerLine": { + zh: "交易所: {exchange} | 交易对: {symbol} | 买一价: {bid} | 卖一价: {ask} | 点差: {spread}", + en: "Exchange: {exchange} | Symbol: {symbol} | Best Bid: {bid} | Best Ask: {ask} | Spread: {spread}", + }, + "makerPoints.markLine": { + zh: "标记价: {mark} | 偏离: {bps} bps | 阻断: {block} bps", + en: "Mark: {mark} | Dislocation: {bps} bps | Block: {block} bps", + }, + "makerPoints.quoteLine": { + zh: "挂单模式: {mode} | BUY {buy} | SELL {sell}", + en: "Quote mode: {mode} | BUY {buy} | SELL {sell}", + }, + "makerPoints.binanceLine": { + zh: "Binance 深度: 买10 {buy} | 卖10 {sell} | 状态: {status}", + en: "Binance depth: bid10 {buy} | ask10 {sell} | Status: {status}", + }, + "makerPoints.mode.closeOnly": { zh: "平仓", en: "Close only" }, + "makerPoints.mode.normal": { zh: "正常", en: "Normal" }, + "makerPoints.feed.binance": { zh: "Binance", en: "Binance" }, "offset.name": { zh: "偏移做市策略", en: "offset maker strategy" }, "offset.title": { zh: "偏移做市策略仪表盘", en: "Offset Maker Strategy Dashboard" }, "offset.initializing": { zh: "正在初始化偏移做市策略…", en: "Initializing offset maker strategy..." }, diff --git a/src/strategy/common/binance-depth.ts b/src/strategy/common/binance-depth.ts new file mode 100644 index 0000000..bafbb89 --- /dev/null +++ b/src/strategy/common/binance-depth.ts @@ -0,0 +1,177 @@ +import NodeWebSocket from "ws"; +import { computeDepthStats, type DepthImbalance } from "../../utils/depth"; + +const WebSocketCtor: typeof globalThis.WebSocket = + typeof globalThis.WebSocket !== "undefined" + ? globalThis.WebSocket + : ((NodeWebSocket as unknown) as typeof globalThis.WebSocket); + +const DEFAULT_BASE_URL = "wss://fstream.binance.com/ws"; + +export interface BinanceDepthSnapshot { + symbol: string; + buySum: number; + sellSum: number; + skipBuySide: boolean; + skipSellSide: boolean; + imbalance: DepthImbalance; + updatedAt: number; +} + +export class BinanceDepthTracker { + private ws: WebSocket | null = null; + private reconnectTimer: ReturnType | null = null; + private reconnectDelayMs = 3000; + private stopped = false; + private snapshot: BinanceDepthSnapshot | null = null; + private listeners = new Set<(snapshot: BinanceDepthSnapshot) => void>(); + + constructor( + private readonly symbol: string, + private readonly options?: { + baseUrl?: string; + levels?: number; + ratio?: number; + logger?: (context: string, error: unknown) => void; + } + ) {} + + start(): void { + this.stopped = false; + this.connect(); + } + + stop(): void { + this.stopped = true; + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + try { + this.ws.close(); + } catch { + // Ignore close errors + } + this.ws = null; + } + } + + onUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void { + this.listeners.add(handler); + } + + offUpdate(handler: (snapshot: BinanceDepthSnapshot) => void): void { + this.listeners.delete(handler); + } + + getSnapshot(): BinanceDepthSnapshot | null { + return this.snapshot ? { ...this.snapshot } : null; + } + + private connect(): void { + if (this.ws || this.stopped) return; + const url = this.buildUrl(); + this.ws = new WebSocketCtor(url); + + const handleOpen = () => { + this.reconnectDelayMs = 3000; + }; + + const handleClose = () => { + this.ws = null; + if (!this.stopped) { + this.scheduleReconnect(); + } + }; + + const handleError = (error: unknown) => { + this.options?.logger?.("binanceDepth", error); + }; + + const handleMessage = (event: { data: unknown }) => { + this.handlePayload(event.data); + }; + + const handlePing = (data: unknown) => { + if (this.ws && "pong" in this.ws && typeof this.ws.pong === "function") { + this.ws.pong(data as any); + } + }; + + if ("addEventListener" in this.ws && typeof this.ws.addEventListener === "function") { + this.ws.addEventListener("open", handleOpen); + this.ws.addEventListener("message", handleMessage as any); + this.ws.addEventListener("close", handleClose); + this.ws.addEventListener("error", handleError as any); + this.ws.addEventListener("ping", handlePing as any); + } else if ("on" in this.ws && typeof (this.ws as any).on === "function") { + const nodeSocket = this.ws as any; + nodeSocket.on("open", handleOpen); + nodeSocket.on("message", (data: unknown) => handleMessage({ data })); + nodeSocket.on("close", handleClose); + nodeSocket.on("error", handleError); + nodeSocket.on("ping", handlePing); + } else { + (this.ws as any).onopen = handleOpen; + (this.ws as any).onmessage = handleMessage; + (this.ws as any).onclose = handleClose; + (this.ws as any).onerror = handleError; + } + } + + private buildUrl(): string { + const base = this.options?.baseUrl ?? DEFAULT_BASE_URL; + const stream = `${this.symbol.toLowerCase()}@depth10@100ms`; + return `${base}/${stream}`; + } + + private scheduleReconnect(): void { + if (this.reconnectTimer || this.stopped) return; + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.reconnectDelayMs = Math.min(this.reconnectDelayMs * 2, 60_000); + this.connect(); + }, this.reconnectDelayMs); + } + + private handlePayload(data: unknown): void { + const payload = this.parsePayload(data); + if (!payload) return; + const bids = Array.isArray(payload.b) ? payload.b : []; + const asks = Array.isArray(payload.a) ? payload.a : []; + const depth = { + lastUpdateId: Number(payload.u ?? Date.now()), + bids, + asks, + }; + const levels = this.options?.levels ?? 10; + const ratio = this.options?.ratio ?? 3; + const stats = computeDepthStats(depth, levels, ratio); + this.snapshot = { + symbol: this.symbol, + buySum: stats.buySum, + sellSum: stats.sellSum, + skipBuySide: stats.skipBuySide, + skipSellSide: stats.skipSellSide, + imbalance: stats.imbalance, + updatedAt: Date.now(), + }; + for (const listener of this.listeners) { + listener({ ...this.snapshot }); + } + } + + private parsePayload(data: unknown): { b?: [string, string][]; a?: [string, string][]; u?: number } | null { + try { + const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf-8") : null; + if (!text) return null; + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object") return null; + return parsed as { b?: [string, string][]; a?: [string, string][]; u?: number }; + } catch { + return null; + } + } +} + diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts new file mode 100644 index 0000000..2d3b7be --- /dev/null +++ b/src/strategy/maker-points-engine.ts @@ -0,0 +1,913 @@ +import type { MakerPointsConfig } from "../config"; +import type { ExchangeAdapter } from "../exchanges/adapter"; +import type { + AsterAccountSnapshot, + AsterDepth, + AsterOrder, + AsterTicker, +} from "../exchanges/types"; +import { formatPriceToString } from "../utils/math"; +import { createTradeLog, type TradeLogEntry } from "../logging/trade-log"; +import { extractMessage, isInsufficientBalanceError, isRateLimitError, isUnknownOrderError } from "../utils/errors"; +import { isOrderActiveStatus } from "../utils/order-status"; +import { getPosition, parseSymbolParts } from "../utils/strategy"; +import type { PositionSnapshot } from "../utils/strategy"; +import { computePositionPnl } from "../utils/pnl"; +import { getTopPrices } from "../utils/price"; +import { + marketClose, + placeOrder, + unlockOperating, +} from "../core/order-coordinator"; +import type { OrderLockMap, OrderPendingMap, OrderTimerMap } from "../core/order-coordinator"; +import { makeOrderPlan } from "../core/lib/order-plan"; +import { safeCancelOrder } from "../core/lib/orders"; +import { RateLimitController } from "../core/lib/rate-limit"; +import { StrategyEventEmitter } from "./common/event-emitter"; +import { safeSubscribe, type LogHandler } from "./common/subscriptions"; +import { SessionVolumeTracker } from "./common/session-volume"; +import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth"; +import { buildBpsTargets, computeDislocationBps } from "./maker-points-logic"; +import { t } from "../i18n"; + +interface DesiredOrder { + side: "BUY" | "SELL"; + price: string; + amount: number; + reduceOnly: boolean; +} + +export interface MakerPointsSnapshot { + ready: boolean; + symbol: string; + topBid: number | null; + topAsk: number | null; + spread: number | null; + markPrice: number | null; + dislocationBps: number | null; + blockedBps: number; + priceDecimals: number; + position: PositionSnapshot; + pnl: number; + accountUnrealized: number; + sessionVolume: number; + openOrders: AsterOrder[]; + desiredOrders: DesiredOrder[]; + tradeLog: TradeLogEntry[]; + lastUpdated: number | null; + feedStatus: { + account: boolean; + orders: boolean; + depth: boolean; + ticker: boolean; + binance: boolean; + }; + binanceDepth: BinanceDepthSnapshot | null; + quoteStatus: { + closeOnly: boolean; + skipBuy: boolean; + skipSell: boolean; + }; +} + +type MakerPointsEvent = "update"; +type MakerPointsListener = (snapshot: MakerPointsSnapshot) => void; + +const EPS = 1e-5; +const INSUFFICIENT_BALANCE_COOLDOWN_MS = 15_000; +const DISLOCATION_THRESHOLD_BPS = 1; +const STOP_LOSS_COOLDOWN_MS = 10_000; + +export class MakerPointsEngine { + private accountSnapshot: AsterAccountSnapshot | null = null; + private depthSnapshot: AsterDepth | null = null; + private tickerSnapshot: AsterTicker | null = null; + private openOrders: AsterOrder[] = []; + + private readonly locks: OrderLockMap = {}; + private readonly timers: OrderTimerMap = {}; + private readonly pending: OrderPendingMap = {}; + private readonly pendingCancelOrders = new Set(); + + private readonly tradeLog: ReturnType; + private readonly events = new StrategyEventEmitter(); + private readonly sessionVolume = new SessionVolumeTracker(); + private readonly rateLimit: RateLimitController; + private readonly binanceDepth: BinanceDepthTracker; + + private priceTick: number = 0.1; + private qtyStep: number = 0.001; + private precisionSync: Promise | null = null; + + private timer: ReturnType | null = null; + private stopLossTimer: ReturnType | null = null; + private processing = false; + private stopLossProcessing = false; + private stopLossCooldownUntil = 0; + private desiredOrders: DesiredOrder[] = []; + private accountUnrealized = 0; + private initialOrderSnapshotReady = false; + private initialOrderResetDone = false; + private entryPricePendingLogged = false; + private lastDesiredSummary: string | null = null; + private lastDislocationBlock = 0; + private lastCloseOnly = false; + private lastSkipBuy = false; + private lastSkipSell = false; + private lastQuoteBid1: number | null = null; + private lastQuoteAsk1: number | null = null; + + private readinessLogged = { + account: false, + depth: false, + ticker: false, + orders: false, + }; + private feedStatus = { + account: false, + depth: false, + ticker: false, + orders: false, + binance: false, + }; + private insufficientBalanceCooldownUntil = 0; + private insufficientBalanceNotified = false; + private lastInsufficientMessage: string | null = null; + + constructor(private readonly config: MakerPointsConfig, private readonly exchange: ExchangeAdapter) { + this.tradeLog = createTradeLog(this.config.maxLogEntries); + this.rateLimit = new RateLimitController(this.config.refreshIntervalMs, (type, detail) => + this.tradeLog.push(type, detail) + ); + this.priceTick = Math.max(1e-9, this.config.priceTick); + this.qtyStep = Math.max(1e-9, this.config.qtyStep); + this.binanceDepth = new BinanceDepthTracker(resolveBinanceSymbol(this.config.symbol), { + baseUrl: process.env.BINANCE_WS_URL, + levels: 10, + ratio: 3, + logger: (context, error) => { + this.tradeLog.push("warn", `Binance ${context} 异常: ${extractMessage(error)}`); + }, + }); + this.binanceDepth.onUpdate(() => { + this.feedStatus.binance = true; + this.emitUpdate(); + }); + this.syncPrecision(); + this.bootstrap(); + } + + start(): void { + if (this.timer) return; + this.timer = setInterval(() => { + void this.tick(); + }, this.config.refreshIntervalMs); + if (!this.stopLossTimer) { + this.stopLossTimer = setInterval(() => { + void this.checkStopLoss(); + }, Math.max(500, this.config.refreshIntervalMs)); + } + this.binanceDepth.start(); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + if (this.stopLossTimer) { + clearInterval(this.stopLossTimer); + this.stopLossTimer = null; + } + this.binanceDepth.stop(); + } + + on(event: MakerPointsEvent, handler: MakerPointsListener): void { + this.events.on(event, handler); + } + + off(event: MakerPointsEvent, handler: MakerPointsListener): void { + this.events.off(event, handler); + } + + getSnapshot(): MakerPointsSnapshot { + return this.buildSnapshot(); + } + + private bootstrap(): void { + const log: LogHandler = (type, detail) => this.tradeLog.push(type, detail); + + safeSubscribe( + this.exchange.watchAccount.bind(this.exchange), + (snapshot) => { + this.accountSnapshot = snapshot; + const totalUnrealized = Number(snapshot.totalUnrealizedProfit ?? "0"); + if (Number.isFinite(totalUnrealized)) { + this.accountUnrealized = totalUnrealized; + } + const position = getPosition(snapshot, this.config.symbol); + this.sessionVolume.update(position, this.getReferencePrice()); + this.feedStatus.account = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.accountFail", { error: String(error) }), + processFail: (error) => t("log.process.accountError", { error: String(error) }), + } + ); + + safeSubscribe( + this.exchange.watchOrders.bind(this.exchange), + (orders) => { + this.syncLocksWithOrders(orders); + this.openOrders = Array.isArray(orders) + ? orders.filter( + (order) => + order.type !== "MARKET" && + order.symbol === this.config.symbol && + isOrderActiveStatus(order.status) + ) + : []; + const currentIds = new Set(this.openOrders.map((order) => String(order.orderId))); + for (const id of Array.from(this.pendingCancelOrders)) { + if (!currentIds.has(id)) { + this.pendingCancelOrders.delete(id); + } + } + this.initialOrderSnapshotReady = true; + this.feedStatus.orders = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.orderFail", { error: String(error) }), + processFail: (error) => t("log.process.orderError", { error: String(error) }), + } + ); + + safeSubscribe( + this.exchange.watchDepth.bind(this.exchange, this.config.symbol), + (depth) => { + this.depthSnapshot = depth; + this.feedStatus.depth = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.depthFail", { error: String(error) }), + processFail: (error) => t("log.process.depthError", { error: String(error) }), + } + ); + + safeSubscribe( + this.exchange.watchTicker.bind(this.exchange, this.config.symbol), + (ticker) => { + this.tickerSnapshot = ticker; + this.feedStatus.ticker = true; + this.emitUpdate(); + }, + log, + { + subscribeFail: (error) => t("log.subscribe.tickerFail", { error: String(error) }), + processFail: (error) => t("log.process.tickerError", { error: String(error) }), + } + ); + } + + private syncLocksWithOrders(orders: AsterOrder[] | null | undefined): void { + const list = Array.isArray(orders) ? orders : []; + Object.keys(this.pending).forEach((type) => { + const pendingId = this.pending[type]; + if (!pendingId) return; + const match = list.find((order) => String(order.orderId) === pendingId); + if (!match || (match.status && match.status !== "NEW" && match.status !== "PARTIALLY_FILLED")) { + unlockOperating(this.locks, this.timers, this.pending, type); + } + }); + } + + private isReady(): boolean { + return Boolean( + this.feedStatus.account && + this.feedStatus.depth && + this.feedStatus.ticker && + this.feedStatus.orders + ); + } + + private async tick(): Promise { + if (this.processing) return; + this.processing = true; + let hadRateLimit = false; + try { + const decision = this.rateLimit.beforeCycle(); + if (decision === "paused") { + this.emitUpdate(); + return; + } + if (decision === "skip") { + return; + } + if (!this.isReady()) { + this.logReadinessBlockers(); + this.emitUpdate(); + return; + } + this.resetReadinessFlags(); + if (!(await this.ensureStartupOrderReset())) { + this.emitUpdate(); + return; + } + + const depth = this.depthSnapshot!; + const { topBid, topAsk } = getTopPrices(depth); + if (topBid == null || topAsk == null) { + this.emitUpdate(); + return; + } + + const position = getPosition(this.accountSnapshot, this.config.symbol); + const absPosition = Math.abs(position.positionAmt); + const closeThreshold = Number(this.config.closeThreshold); + const closeOnly = + Number.isFinite(closeThreshold) && + closeThreshold > 0 && + absPosition >= closeThreshold - EPS; + const prevCloseOnly = this.lastCloseOnly; + if (closeOnly !== prevCloseOnly) { + this.tradeLog.push("info", closeOnly ? "进入平仓模式,仅挂 reduce-only" : "退出平仓模式"); + this.lastCloseOnly = closeOnly; + } + + const markPrice = this.getMarkPrice(); + const hasMarkPrice = Number.isFinite(markPrice) && (markPrice ?? 0) > 0; + if (!hasMarkPrice && !closeOnly) { + if (!this.entryPricePendingLogged) { + this.tradeLog.push("info", "等待标记价格推送…"); + this.entryPricePendingLogged = true; + } + this.emitUpdate(); + return; + } + if (hasMarkPrice) { + this.entryPricePendingLogged = false; + } + + const resolvedMarkPrice = hasMarkPrice ? Number(markPrice) : 0; + const dislocationBps = hasMarkPrice ? computeDislocationBps(resolvedMarkPrice, topBid, topAsk) : null; + const blockBps = + dislocationBps != null && dislocationBps > DISLOCATION_THRESHOLD_BPS + ? Math.floor(dislocationBps + 1e-9) + : 0; + const prevBlockBps = this.lastDislocationBlock; + if (blockBps !== prevBlockBps) { + if (blockBps > 0) { + this.tradeLog.push("warn", `标记价偏离盘口 ${blockBps} bps,撤销该范围挂单`); + } else if (prevBlockBps > 0) { + this.tradeLog.push("info", "标记价偏离已恢复,恢复挂单"); + } + this.lastDislocationBlock = blockBps; + } + + const binanceSnapshot = this.binanceDepth.getSnapshot(); + const rawSkipBuy = Boolean(binanceSnapshot?.skipBuySide); + const rawSkipSell = Boolean(binanceSnapshot?.skipSellSide); + const skipBuy = closeOnly ? false : rawSkipBuy; + const skipSell = closeOnly ? false : rawSkipSell; + const prevSkipBuy = this.lastSkipBuy; + const prevSkipSell = this.lastSkipSell; + if (skipBuy !== prevSkipBuy || skipSell !== prevSkipSell) { + if (skipBuy || skipSell) { + const summary = `${skipBuy ? "BUY" : ""}${skipBuy && skipSell ? "/" : ""}${skipSell ? "SELL" : ""}`; + this.tradeLog.push("info", `Binance 深度失衡,暂停 ${summary} 挂单`); + } else { + this.tradeLog.push("info", "Binance 深度恢复,继续挂单"); + } + this.lastSkipBuy = skipBuy; + this.lastSkipSell = skipSell; + } + + const blockChanged = blockBps !== prevBlockBps; + const closeOnlyChanged = closeOnly !== prevCloseOnly; + const skipChanged = skipBuy !== prevSkipBuy || skipSell !== prevSkipSell; + const repriceNeeded = closeOnly ? true : this.shouldReprice(topBid, topAsk); + const shouldRecompute = + closeOnly || + repriceNeeded || + blockChanged || + closeOnlyChanged || + skipChanged || + this.desiredOrders.length === 0; + + const desired = shouldRecompute + ? closeOnly + ? this.buildCloseOnlyOrders(position, topBid, topAsk) + : this.buildDesiredOrders({ + bid1: topBid, + ask1: topAsk, + markPrice: resolvedMarkPrice, + blockBps, + skipBuy, + skipSell, + }) + : this.desiredOrders; + + if (shouldRecompute) { + if (closeOnly) { + this.lastQuoteBid1 = null; + this.lastQuoteAsk1 = null; + } else { + this.lastQuoteBid1 = topBid; + this.lastQuoteAsk1 = topAsk; + } + } + + this.desiredOrders = desired; + this.logDesiredOrders(desired); + this.sessionVolume.update(position, this.getReferencePrice()); + await this.syncOrders(desired, resolvedMarkPrice, closeOnly); + this.emitUpdate(); + } catch (error) { + if (isRateLimitError(error)) { + hadRateLimit = true; + this.rateLimit.registerRateLimit("maker-points"); + this.tradeLog.push("warn", `限频触发,暂停挂单: ${extractMessage(error)}`); + } else { + this.tradeLog.push("error", `MakerPoints 主循环异常: ${extractMessage(error)}`); + } + this.emitUpdate(); + } finally { + this.rateLimit.onCycleComplete(hadRateLimit); + this.processing = false; + } + } + + private buildDesiredOrders(params: { + bid1: number; + ask1: number; + markPrice: number; + blockBps: number; + skipBuy: boolean; + skipSell: boolean; + }): DesiredOrder[] { + const { bid1, ask1, markPrice, blockBps, skipBuy, skipSell } = params; + const amount = Number(this.config.perOrderAmount); + if (!Number.isFinite(amount) || amount <= 0) return []; + + const targets = buildBpsTargets({ + band0To10: this.config.enableBand0To10, + band10To30: this.config.enableBand10To30, + band30To100: this.config.enableBand30To100, + }).sort((a, b) => b - a); + + if (!targets.length) return []; + + const priceDecimals = this.getPriceDecimals(); + const desired: DesiredOrder[] = []; + + for (const bps of targets) { + if (!skipBuy) { + const price = bid1 * (1 - bps / 10000); + const distanceBps = (markPrice - price) / markPrice * 10000; + if ( + Number.isFinite(price) && + price > 0 && + (!Number.isFinite(distanceBps) || distanceBps > blockBps) + ) { + desired.push({ + side: "BUY", + price: formatPriceToString(price, priceDecimals), + amount, + reduceOnly: false, + }); + } + } + if (!skipSell) { + const price = ask1 * (1 + bps / 10000); + const distanceBps = (price - markPrice) / markPrice * 10000; + if ( + Number.isFinite(price) && + price > 0 && + (!Number.isFinite(distanceBps) || distanceBps > blockBps) + ) { + desired.push({ + side: "SELL", + price: formatPriceToString(price, priceDecimals), + amount, + reduceOnly: false, + }); + } + } + } + + return desired; + } + + private buildCloseOnlyOrders( + position: PositionSnapshot, + bid1: number, + ask1: number + ): DesiredOrder[] { + const absPosition = Math.abs(position.positionAmt); + if (absPosition < EPS) return []; + const priceDecimals = this.getPriceDecimals(); + if (position.positionAmt > 0) { + return [ + { + side: "SELL", + price: formatPriceToString(bid1, priceDecimals), + amount: absPosition, + reduceOnly: true, + }, + ]; + } + return [ + { + side: "BUY", + price: formatPriceToString(ask1, priceDecimals), + amount: absPosition, + reduceOnly: true, + }, + ]; + } + + private shouldReprice(bid1: number, ask1: number): boolean { + const threshold = Number(this.config.minRepriceBps); + if (!Number.isFinite(threshold) || threshold <= 0) return true; + if (!Number.isFinite(bid1) || !Number.isFinite(ask1)) return false; + if (!Number.isFinite(this.lastQuoteBid1 ?? NaN) || !Number.isFinite(this.lastQuoteAsk1 ?? NaN)) { + return true; + } + if ((this.lastQuoteBid1 ?? 0) <= 0 || (this.lastQuoteAsk1 ?? 0) <= 0) return true; + const bidMove = Math.abs(bid1 - (this.lastQuoteBid1 ?? bid1)) / (this.lastQuoteBid1 ?? bid1) * 10000; + const askMove = Math.abs(ask1 - (this.lastQuoteAsk1 ?? ask1)) / (this.lastQuoteAsk1 ?? ask1) * 10000; + return bidMove >= threshold || askMove >= threshold; + } + + private async ensureStartupOrderReset(): Promise { + if (this.initialOrderResetDone) return true; + if (!this.initialOrderSnapshotReady) return false; + if (!this.openOrders.length) { + this.initialOrderResetDone = true; + return true; + } + try { + await this.exchange.cancelAllOrders({ symbol: this.config.symbol }); + this.pendingCancelOrders.clear(); + unlockOperating(this.locks, this.timers, this.pending, "LIMIT"); + this.openOrders = []; + this.emitUpdate(); + this.tradeLog.push("order", "启动时清理历史挂单"); + this.initialOrderResetDone = true; + return true; + } catch (error) { + if (isUnknownOrderError(error)) { + this.tradeLog.push("order", "历史挂单已消失,跳过启动清理"); + this.initialOrderResetDone = true; + this.openOrders = []; + this.emitUpdate(); + return true; + } + this.tradeLog.push("error", `启动撤单失败: ${String(error)}`); + return false; + } + } + + private async syncOrders(targets: DesiredOrder[], markPrice: number, closeOnly: boolean): Promise { + const availableOrders = this.openOrders.filter((o) => !this.pendingCancelOrders.has(String(o.orderId))); + const openOrders = availableOrders.filter((order) => isOrderActiveStatus(order.status)); + const { toCancel, toPlace } = makeOrderPlan(openOrders, targets); + + for (const order of toCancel) { + if (this.pendingCancelOrders.has(String(order.orderId))) continue; + this.pendingCancelOrders.add(String(order.orderId)); + await safeCancelOrder( + this.exchange, + this.config.symbol, + order, + () => { + this.tradeLog.push( + "order", + `撤销不匹配订单 ${order.side} @ ${order.price} reduceOnly=${order.reduceOnly}` + ); + }, + () => { + this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略"); + this.pendingCancelOrders.delete(String(order.orderId)); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); + }, + (error) => { + this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); + this.pendingCancelOrders.delete(String(order.orderId)); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); + } + ); + } + + const insufficientActive = this.applyInsufficientBalanceState(Date.now()); + if (this.rateLimit.shouldBlockEntries() || insufficientActive) { + return; + } + + for (const target of toPlace) { + if (!target) continue; + if (target.amount < EPS) continue; + try { + await placeOrder( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + target.side, + target.price, + target.amount, + (type, detail) => this.tradeLog.push(type, detail), + target.reduceOnly, + closeOnly + ? undefined + : { + markPrice, + maxPct: this.config.maxCloseSlippagePct, + }, + { + priceTick: this.priceTick, + qtyStep: this.qtyStep, + } + ); + } catch (error) { + if (isInsufficientBalanceError(error)) { + this.registerInsufficientBalance(error); + break; + } + this.tradeLog.push( + "error", + `挂单失败 ${target.side} @ ${target.price}: ${extractMessage(error)}` + ); + } + } + } + + private async checkStopLoss(): Promise { + if (this.stopLossProcessing) return; + const lossLimit = Number(this.config.stopLossUsd); + if (!Number.isFinite(lossLimit) || lossLimit <= 0) return; + if (!this.accountSnapshot) return; + const position = getPosition(this.accountSnapshot, this.config.symbol); + const absPosition = Math.abs(position.positionAmt); + if (absPosition < EPS) return; + if (!Number.isFinite(position.unrealizedProfit)) return; + + const now = Date.now(); + if (now < this.stopLossCooldownUntil) return; + if (position.unrealizedProfit > -lossLimit) return; + + this.stopLossProcessing = true; + this.stopLossCooldownUntil = now + STOP_LOSS_COOLDOWN_MS; + this.tradeLog.push( + "stop", + `触发止损: 未实现亏损 ${position.unrealizedProfit.toFixed(4)} USDT` + ); + try { + await this.flushOrders(); + await marketClose( + this.exchange, + this.config.symbol, + this.openOrders, + this.locks, + this.timers, + this.pending, + position.positionAmt > 0 ? "SELL" : "BUY", + absPosition, + (type, detail) => this.tradeLog.push(type, detail), + undefined, + { qtyStep: this.qtyStep } + ); + } catch (error) { + if (isUnknownOrderError(error)) { + this.tradeLog.push("order", "止损平仓时订单已不存在"); + } else { + this.tradeLog.push("error", `止损平仓失败: ${extractMessage(error)}`); + } + } finally { + this.stopLossProcessing = false; + this.emitUpdate(); + } + } + + private async flushOrders(): Promise { + if (!this.openOrders.length) return; + for (const order of this.openOrders) { + if (this.pendingCancelOrders.has(String(order.orderId))) continue; + this.pendingCancelOrders.add(String(order.orderId)); + await safeCancelOrder( + this.exchange, + this.config.symbol, + order, + () => { + // No log on successful cancel + }, + () => { + this.tradeLog.push("order", "撤销时发现订单已被成交/取消,忽略"); + this.pendingCancelOrders.delete(String(order.orderId)); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); + }, + (error) => { + this.tradeLog.push("error", `撤销订单失败: ${String(error)}`); + this.pendingCancelOrders.delete(String(order.orderId)); + this.openOrders = this.openOrders.filter((existing) => existing.orderId !== order.orderId); + } + ); + } + } + + private syncPrecision(): void { + if (this.precisionSync) return; + const getPrecision = this.exchange.getPrecision?.bind(this.exchange); + if (!getPrecision) return; + this.precisionSync = getPrecision() + .then((precision) => { + if (!precision) return; + let updated = false; + if (Number.isFinite(precision.priceTick) && precision.priceTick > 0) { + if (Math.abs(precision.priceTick - this.priceTick) > 1e-12) { + this.priceTick = precision.priceTick; + this.config.priceTick = precision.priceTick; + updated = true; + } + } + if (Number.isFinite(precision.qtyStep) && precision.qtyStep > 0) { + if (Math.abs(precision.qtyStep - this.qtyStep) > 1e-12) { + this.qtyStep = precision.qtyStep; + updated = true; + } + } + if (updated) { + this.tradeLog.push( + "info", + t("log.common.precisionSynced", { + priceTick: precision.priceTick, + qtyStep: precision.qtyStep, + }) + ); + } + }) + .catch((error) => { + this.tradeLog.push("error", t("log.common.precisionFailed", { error: extractMessage(error) })); + this.precisionSync = null; + setTimeout(() => this.syncPrecision(), 2000); + }); + } + + private getPriceDecimals(): number { + const tick = Math.max(1e-9, this.priceTick); + const raw = Math.log10(1 / tick); + if (!Number.isFinite(raw)) return 0; + return Math.max(0, Math.floor(raw + 1e-9)); + } + + private emitUpdate(): void { + try { + const snapshot = this.buildSnapshot(); + this.events.emit("update", snapshot, (error) => { + this.tradeLog.push("error", `更新监听异常: ${String(error)}`); + }); + } catch (err) { + this.tradeLog.push("error", `快照生成异常: ${String(err)}`); + } + } + + private buildSnapshot(): MakerPointsSnapshot { + const position = getPosition(this.accountSnapshot, this.config.symbol); + const { topBid, topAsk } = getTopPrices(this.depthSnapshot); + const spread = topBid != null && topAsk != null ? topAsk - topBid : null; + const markPrice = this.getMarkPrice(); + const dislocationBps = computeDislocationBps(markPrice, topBid, topAsk); + const blockBps = + dislocationBps != null && dislocationBps > DISLOCATION_THRESHOLD_BPS + ? Math.floor(dislocationBps + 1e-9) + : 0; + const pnl = computePositionPnl(position, topBid, topAsk); + + return { + ready: this.isReady(), + symbol: this.config.symbol, + topBid, + topAsk, + spread, + markPrice, + dislocationBps, + blockedBps: blockBps, + priceDecimals: this.getPriceDecimals(), + position, + pnl, + accountUnrealized: this.accountUnrealized, + sessionVolume: this.sessionVolume.value, + openOrders: this.openOrders, + desiredOrders: this.desiredOrders, + tradeLog: this.tradeLog.all(), + lastUpdated: Date.now(), + feedStatus: { ...this.feedStatus }, + binanceDepth: this.binanceDepth.getSnapshot(), + quoteStatus: { + closeOnly: this.lastCloseOnly, + skipBuy: this.lastSkipBuy, + skipSell: this.lastSkipSell, + }, + }; + } + + private getReferencePrice(): number | null { + const mark = Number(this.tickerSnapshot?.markPrice); + if (Number.isFinite(mark) && mark > 0) return mark; + const last = Number(this.tickerSnapshot?.lastPrice); + return Number.isFinite(last) && last > 0 ? last : null; + } + + private getMarkPrice(): number | null { + const mark = Number(this.tickerSnapshot?.markPrice); + if (Number.isFinite(mark) && mark > 0) return mark; + const positionMark = Number(getPosition(this.accountSnapshot, this.config.symbol).markPrice); + if (Number.isFinite(positionMark) && positionMark > 0) return positionMark; + const last = Number(this.tickerSnapshot?.lastPrice); + return Number.isFinite(last) && last > 0 ? last : null; + } + + private logReadinessBlockers(): void { + if (!this.feedStatus.account && !this.readinessLogged.account) { + this.tradeLog.push("info", t("log.maker.waitAccount")); + this.readinessLogged.account = true; + } + if (!this.feedStatus.depth && !this.readinessLogged.depth) { + this.tradeLog.push("info", t("log.maker.waitDepth")); + this.readinessLogged.depth = true; + } + if (!this.feedStatus.ticker && !this.readinessLogged.ticker) { + this.tradeLog.push("info", t("log.maker.waitTicker")); + this.readinessLogged.ticker = true; + } + if (!this.feedStatus.orders && !this.readinessLogged.orders) { + this.tradeLog.push("info", t("log.maker.waitOrders")); + this.readinessLogged.orders = true; + } + } + + private resetReadinessFlags(): void { + this.readinessLogged = { + account: false, + depth: false, + ticker: false, + orders: false, + }; + } + + private logDesiredOrders(desired: DesiredOrder[]): void { + if (!desired.length) { + if (this.lastDesiredSummary !== "none") { + this.tradeLog.push("info", "暂无目标挂单"); + this.lastDesiredSummary = "none"; + } + return; + } + const summary = desired + .map((order) => `${order.side}@${order.price}${order.reduceOnly ? "(RO)" : ""}`) + .join(" | "); + if (summary !== this.lastDesiredSummary) { + this.tradeLog.push("info", `目标挂单: ${summary}`); + this.lastDesiredSummary = summary; + } + } + + private registerInsufficientBalance(error: unknown): void { + const now = Date.now(); + const detail = extractMessage(error); + const alreadyActive = now < this.insufficientBalanceCooldownUntil; + if (alreadyActive && detail === this.lastInsufficientMessage) { + this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS; + return; + } + this.insufficientBalanceCooldownUntil = now + INSUFFICIENT_BALANCE_COOLDOWN_MS; + this.lastInsufficientMessage = detail; + const seconds = Math.ceil(INSUFFICIENT_BALANCE_COOLDOWN_MS / 1000); + this.tradeLog.push("warn", `余额不足,暂停挂单 ${seconds}s: ${detail}`); + this.insufficientBalanceNotified = true; + } + + private applyInsufficientBalanceState(now: number): boolean { + const active = now < this.insufficientBalanceCooldownUntil; + if (!active && this.insufficientBalanceNotified) { + this.tradeLog.push("info", "余额恢复,继续挂单"); + this.insufficientBalanceNotified = false; + this.lastInsufficientMessage = null; + } + return active; + } +} + +function resolveBinanceSymbol(symbol: string): string { + const parts = parseSymbolParts(symbol); + const base = (parts.base ?? symbol).replace(/[^a-zA-Z0-9]/g, "").toUpperCase(); + return base ? `${base}USDT` : "BTCUSDT"; +} diff --git a/src/strategy/maker-points-logic.test.ts b/src/strategy/maker-points-logic.test.ts new file mode 100644 index 0000000..d9bf757 --- /dev/null +++ b/src/strategy/maker-points-logic.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { buildBpsTargets, computeDislocationBps } from "./maker-points-logic"; + +describe("maker points target builder", () => { + it("builds fixed bps targets per enabled band", () => { + const targets = buildBpsTargets({ + band0To10: true, + band10To30: true, + band30To100: true, + }); + expect(targets).toEqual([9, 29, 99]); + }); + + it("skips disabled bands", () => { + const targets = buildBpsTargets({ + band0To10: true, + band10To30: false, + band30To100: true, + }); + expect(targets).toEqual([9, 99]); + }); +}); + +describe("maker points dislocation", () => { + it("computes max bps dislocation from mark vs bid/ask", () => { + const bps = computeDislocationBps(100, 99.97, 100.02); + expect(bps).not.toBeNull(); + expect(Number(bps?.toFixed(2))).toBe(3.0); + }); +}); diff --git a/src/strategy/maker-points-logic.ts b/src/strategy/maker-points-logic.ts new file mode 100644 index 0000000..e7dcb03 --- /dev/null +++ b/src/strategy/maker-points-logic.ts @@ -0,0 +1,34 @@ +export interface MakerPointsBandConfig { + band0To10: boolean; + band10To30: boolean; + band30To100: boolean; +} + +export function buildBpsTargets(config: MakerPointsBandConfig): number[] { + const targets: number[] = []; + if (config.band0To10) targets.push(9); + if (config.band10To30) targets.push(29); + if (config.band30To100) targets.push(99); + return targets.sort((a, b) => a - b); +} + +export function computeDislocationBps( + markPrice: number | null | undefined, + bestBid: number | null | undefined, + bestAsk: number | null | undefined +): number | null { + const mark = Number(markPrice); + if (!Number.isFinite(mark) || mark <= 0) return null; + const distances: number[] = []; + const bid = Number(bestBid); + const ask = Number(bestAsk); + if (Number.isFinite(bid)) { + distances.push(Math.abs(mark - bid) / mark * 10000); + } + if (Number.isFinite(ask)) { + distances.push(Math.abs(ask - mark) / mark * 10000); + } + if (!distances.length) return null; + const max = Math.max(...distances); + return Number.isFinite(max) ? max : null; +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 489c660..9ae784d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -3,6 +3,7 @@ import { Box, Text, useInput } from "ink"; import { TrendApp } from "./TrendApp"; import { GuardianApp } from "./GuardianApp"; import { MakerApp } from "./MakerApp"; +import { MakerPointsApp } from "./MakerPointsApp"; import { OffsetMakerApp } from "./OffsetMakerApp"; import { GridApp } from "./GridApp"; import { BasisApp } from "./BasisApp"; @@ -12,7 +13,7 @@ import { resolveExchangeId } from "../exchanges/create-adapter"; import { t } from "../i18n"; interface StrategyOption { - id: "trend" | "guardian" | "maker" | "offset-maker" | "basis" | "grid"; + id: "trend" | "guardian" | "maker" | "maker-points" | "offset-maker" | "basis" | "grid"; label: string; description: string; component: React.ComponentType<{ onExit: () => void }>; @@ -60,19 +61,25 @@ export function App() { const integrityOk = useMemo(() => verifyCopyrightIntegrity(), []); const exchangeId = useMemo(() => resolveExchangeId(), []); const strategies = useMemo(() => { - if (!isBasisStrategyEnabled()) { - return BASE_STRATEGIES; + const next: StrategyOption[] = [...BASE_STRATEGIES]; + if (exchangeId === "standx") { + next.splice(3, 0, { + id: "maker-points" as const, + label: t("app.strategy.makerPoints.label"), + description: t("app.strategy.makerPoints.desc"), + component: MakerPointsApp, + }); } - return [ - ...BASE_STRATEGIES, - { + if (isBasisStrategyEnabled()) { + next.push({ id: "basis" as const, label: t("app.strategy.basis.label"), description: t("app.strategy.basis.desc"), component: BasisApp, - }, - ]; - }, []); + }); + } + return next; + }, [exchangeId]); useInput( (input, key) => { diff --git a/src/ui/MakerPointsApp.tsx b/src/ui/MakerPointsApp.tsx new file mode 100644 index 0000000..01ac2f3 --- /dev/null +++ b/src/ui/MakerPointsApp.tsx @@ -0,0 +1,250 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { Box, Text, useInput } from "ink"; +import { makerPointsConfig } from "../config"; +import { getExchangeDisplayName, resolveExchangeId } from "../exchanges/create-adapter"; +import { buildAdapterFromEnv } from "../exchanges/resolve-from-env"; +import { MakerPointsEngine, type MakerPointsSnapshot } from "../strategy/maker-points-engine"; +import { DataTable, type TableColumn } from "./components/DataTable"; +import { formatNumber } from "../utils/format"; +import { t } from "../i18n"; + +interface MakerPointsAppProps { + onExit: () => void; +} + +const inputSupported = Boolean(process.stdin && (process.stdin as any).isTTY); + +export function MakerPointsApp({ onExit }: MakerPointsAppProps) { + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const engineRef = useRef(null); + const exchangeId = useMemo(() => resolveExchangeId(), []); + const exchangeName = useMemo(() => getExchangeDisplayName(exchangeId), [exchangeId]); + + useInput( + (input, key) => { + if (key.escape) { + engineRef.current?.stop(); + onExit(); + } + }, + { isActive: inputSupported } + ); + + useEffect(() => { + try { + if (exchangeId !== "standx") { + throw new Error("Maker Points strategy only supports the StandX exchange."); + } + const adapter = buildAdapterFromEnv({ exchangeId, symbol: makerPointsConfig.symbol }); + const engine = new MakerPointsEngine(makerPointsConfig, adapter); + engineRef.current = engine; + setSnapshot(engine.getSnapshot()); + const handler = (next: MakerPointsSnapshot) => { + setSnapshot({ ...next, tradeLog: [...next.tradeLog] }); + }; + engine.on("update", handler); + engine.start(); + return () => { + engine.off("update", handler); + engine.stop(); + }; + } catch (err) { + console.error(err); + setError(err instanceof Error ? err : new Error(String(err))); + } + }, [exchangeId]); + + if (error) { + return ( + + {t("common.startFailed", { message: error.message })} + {t("common.checkEnv")} + + ); + } + + if (!snapshot) { + return ( + + {t("makerPoints.initializing")} + + ); + } + + const topBid = snapshot.topBid; + const topAsk = snapshot.topAsk; + const priceDigits = snapshot.priceDecimals ?? 2; + const spreadDigits = Math.max(priceDigits + 1, 4); + const spreadDisplay = + snapshot.spread != null ? `${formatNumber(snapshot.spread, spreadDigits)} USDT` : "-"; + const hasPosition = Math.abs(snapshot.position.positionAmt) > 1e-5; + const markDisplay = Number.isFinite(snapshot.markPrice) ? formatNumber(snapshot.markPrice, priceDigits) : "-"; + const dislocationDisplay = + snapshot.dislocationBps != null ? formatNumber(snapshot.dislocationBps, 2) : "-"; + const blockDisplay = snapshot.blockedBps > 0 ? String(snapshot.blockedBps) : "-"; + + const sortedOrders = [...snapshot.openOrders].sort((a, b) => + (Number(b.updateTime ?? 0) - Number(a.updateTime ?? 0)) || Number(b.orderId) - Number(a.orderId) + ); + const openOrderRows = sortedOrders.slice(0, 8).map((order) => ({ + id: order.orderId, + side: order.side, + price: order.price, + qty: order.origQty, + filled: order.executedQty, + reduceOnly: order.reduceOnly ? "yes" : "no", + status: order.status, + })); + const openOrderColumns: TableColumn[] = [ + { key: "id", header: "ID", align: "right", minWidth: 6 }, + { key: "side", header: "Side", minWidth: 4 }, + { key: "price", header: "Price", align: "right", minWidth: 10 }, + { key: "qty", header: "Qty", align: "right", minWidth: 8 }, + { key: "filled", header: "Filled", align: "right", minWidth: 8 }, + { key: "reduceOnly", header: "RO", minWidth: 4 }, + { key: "status", header: "Status", minWidth: 10 }, + ]; + + const desiredRows = snapshot.desiredOrders.map((order, index) => ({ + index: index + 1, + side: order.side, + price: order.price, + amount: order.amount, + reduceOnly: order.reduceOnly ? "yes" : "no", + })); + const desiredColumns: TableColumn[] = [ + { key: "index", header: "#", align: "right", minWidth: 2 }, + { key: "side", header: "Side", minWidth: 4 }, + { key: "price", header: "Price", align: "right", minWidth: 10 }, + { key: "amount", header: "Qty", align: "right", minWidth: 8 }, + { key: "reduceOnly", header: "RO", minWidth: 4 }, + ]; + + const lastLogs = snapshot.tradeLog.slice(-5); + const feedStatus = snapshot.feedStatus; + const feedEntries: Array<{ key: keyof typeof feedStatus; label: string }> = [ + { key: "account", label: t("maker.feed.account") }, + { key: "orders", label: t("maker.feed.orders") }, + { key: "depth", label: t("maker.feed.depth") }, + { key: "ticker", label: t("maker.feed.ticker") }, + { key: "binance", label: t("makerPoints.feed.binance") }, + ]; + const readyStatus = snapshot.ready ? t("status.live") : t("status.waitingData"); + const imbalanceStatus = snapshot.binanceDepth?.imbalance ?? "balanced"; + const imbalanceLabel = + imbalanceStatus === "buy_dominant" + ? t("offset.imbalance.buy") + : imbalanceStatus === "sell_dominant" + ? t("offset.imbalance.sell") + : t("offset.imbalance.balanced"); + const quoteMode = snapshot.quoteStatus.closeOnly ? t("makerPoints.mode.closeOnly") : t("makerPoints.mode.normal"); + + return ( + + + {t("makerPoints.title")} + + {t("makerPoints.headerLine", { + exchange: exchangeName, + symbol: snapshot.symbol, + bid: formatNumber(topBid, priceDigits), + ask: formatNumber(topAsk, priceDigits), + spread: spreadDisplay, + })} + + + {t("makerPoints.markLine", { + mark: markDisplay, + bps: dislocationDisplay, + block: blockDisplay, + })} + + {t("trend.statusLine", { status: readyStatus })} + + {t("makerPoints.quoteLine", { + mode: quoteMode, + buy: snapshot.quoteStatus.skipBuy ? t("common.disabled") : t("common.enabled"), + sell: snapshot.quoteStatus.skipSell ? t("common.disabled") : t("common.enabled"), + })} + + + {t("makerPoints.binanceLine", { + buy: formatNumber(snapshot.binanceDepth?.buySum ?? 0, 4), + sell: formatNumber(snapshot.binanceDepth?.sellSum ?? 0, 4), + status: imbalanceLabel, + })} + + + {t("maker.dataStatus")} + {feedEntries.map((entry, index) => ( + + {index === 0 ? " " : " "} + {entry.label} + + ))} + + + + + + {t("common.section.position")} + {hasPosition ? ( + <> + + {t("maker.positionLine", { + direction: + snapshot.position.positionAmt > 0 ? t("common.direction.long") : t("common.direction.short"), + qty: formatNumber(Math.abs(snapshot.position.positionAmt), 4), + entry: formatNumber(snapshot.position.entryPrice, priceDigits), + })} + + + {t("maker.pnlLine", { + pnl: formatNumber(snapshot.pnl, 4), + accountPnl: formatNumber(snapshot.accountUnrealized, 4), + })} + + + ) : ( + {t("common.noPosition")} + )} + + + {t("maker.targetOrders")} + {desiredRows.length > 0 ? ( + + ) : ( + {t("maker.noTargetOrders")} + )} + + {t("trend.volumeLine", { volume: formatNumber(snapshot.sessionVolume, 2) })} + + + + + + {t("common.section.orders")} + {openOrderRows.length > 0 ? ( + + ) : ( + {t("common.noOrders")} + )} + + + + {t("common.section.recent")} + {lastLogs.length > 0 ? ( + lastLogs.map((item, index) => ( + + [{item.time}] [{item.type}] {item.detail} + + )) + ) : ( + {t("common.noLogs")} + )} + + + ); +} +