Add token expiry and Telegram notification features

- Introduced `STANDX_TOKEN_EXPIRY` configuration to manage token expiration, including handling logic for active, expired, and silent states.
- Implemented Telegram notifications for key events such as order filled, position opened/closed, stop loss triggered, and token expiration.
- Updated Maker Points engine to integrate token expiry checks and notification sending, enhancing user awareness of trading conditions.
- Enhanced documentation to include details on configuring token expiry and Telegram notifications for improved user guidance.
This commit is contained in:
discountry
2026-01-10 12:44:52 +08:00
parent cb1cef6f1b
commit 598f2a0eb6
8 changed files with 611 additions and 4 deletions
+9
View File
@@ -16,6 +16,10 @@ STANDX_SYMBOL=BTC-USD
# STANDX_SESSION_ID=
# Optional: request signing key (ed25519 private key, hex or base64)
# STANDX_REQUEST_PRIVATE_KEY=
# Token expiry timestamp (Unix seconds). When expired: cancels all orders, stops new orders.
# If position exists, continues close/stop-loss logic. If no position/orders, enters silent mode.
# Example: STANDX_TOKEN_EXPIRY=1737092800
# STANDX_TOKEN_EXPIRY=
# Core trading symbol and sizing
TRADE_SYMBOL=BTCUSDT # Trading pair symbol
@@ -154,3 +158,8 @@ NADO_MIN_SIZE_POLICY=adjust
# NADO_ARCHIVE_URL=https://archive.prod.nado.xyz/v1
# NADO_TRIGGER_URL=https://trigger.prod.nado.xyz/v1
# NADO_DEBUG=false
# Telegram notification configuration
# TELEGRAM_BOT_TOKEN= # Telegram bot token from @BotFather
# TELEGRAM_CHAT_ID= # Chat ID to receive notifications
# TELEGRAM_ACCOUNT_LABEL= # Account label to distinguish multiple bot instances (e.g., "Account-A")
+103
View File
@@ -146,6 +146,22 @@ MAKER_POINTS_MIN_REPRICE_BPS=3
MAKER_POINTS_BAND_0_10=true
MAKER_POINTS_BAND_10_30=true
MAKER_POINTS_BAND_30_100=true
# ===== Token 过期时间配置(可选) =====
# 配置 Token 过期时间后,策略会在 Token 过期前提醒你,过期后自动进入安全模式
# 格式1:时间戳(毫秒),例如:1735689600000
# 格式2:时间戳(秒),例如:1735689600(会自动转换为毫秒)
# 格式3:ISO 日期字符串,例如:2025-01-01T00:00:00Z
# 如果不配置此项,策略不会检查 Token 过期时间
# STANDX_TOKEN_EXPIRY=1735689600000
# ===== Telegram 通知配置(可选) =====
# 配置后,策略会通过 Telegram 发送重要通知(订单成交、开仓、平仓、止损、Token过期等)
# 如何获取 Bot Token:在 Telegram 搜索 @BotFather,发送 /newbot 创建机器人,获取 Token
# 如何获取 Chat ID:在 Telegram 搜索 @userinfobot,发送任意消息即可看到你的 Chat ID
# TELEGRAM_BOT_TOKEN=你的BotToken
# TELEGRAM_CHAT_ID=你的ChatID
# TELEGRAM_ACCOUNT_LABEL=我的账户(可选,用于区分多个账户的通知)
```
### 正确填写示例
@@ -168,6 +184,9 @@ MAKER_POINTS_MIN_REPRICE_BPS=3
MAKER_POINTS_BAND_0_10=true
MAKER_POINTS_BAND_10_30=true
MAKER_POINTS_BAND_30_100=true
# STANDX_TOKEN_EXPIRY=1735689600000
# TELEGRAM_BOT_TOKEN=你的BotToken
# TELEGRAM_CHAT_ID=你的ChatID
```
> 🔴 **不要加引号!不要加空格!直接粘贴值!**
@@ -203,6 +222,79 @@ bun run pm2:start:maker-points
| `MAKER_POINTS_CLOSE_THRESHOLD` | 持仓达到多少开始平仓 | 设为 `0` 表示不自动平仓 |
| `MAKER_POINTS_STOP_LOSS_USD` | 亏损多少美元强制平仓 | 设为 `0` 表示关闭止损 |
| `MAKER_POINTS_BAND_*` | 三个挂单档位的开关 | 全部 `true` 即可 |
| `STANDX_TOKEN_EXPIRY` | Token 过期时间 | 可选,格式见下方说明 |
| `TELEGRAM_BOT_TOKEN` | Telegram 机器人 Token | 可选,用于接收通知 |
| `TELEGRAM_CHAT_ID` | Telegram 聊天 ID | 可选,配合 Bot Token 使用 |
| `TELEGRAM_ACCOUNT_LABEL` | Telegram 通知账户标签 | 可选,用于区分多个账户 |
### Token 过期时间配置详解
`STANDX_TOKEN_EXPIRY` 用于设置 Token 的过期时间。配置后,策略会:
1. **Token 过期前 1 小时**:在日志中提醒你 Token 即将过期
2. **Token 过期后**
- 如果有持仓:进入**平仓模式**,只允许平仓和止损,不再开新仓
- 如果无持仓但有挂单:**自动取消所有挂单**
- 如果无持仓无挂单:进入**静默模式**,只接收数据,不下单
**支持的格式:**
- **时间戳(毫秒)**`1735689600000`
- **时间戳(秒)**`1735689600`(会自动转换为毫秒)
- **ISO 日期字符串**`2025-01-01T00:00:00Z``2025-01-01 00:00:00`
**如何获取 Token 过期时间?**
登录 standx.ritmex.one 时,系统会返回 Token 的有效期。你可以在导出登录信息时查看,或者根据登录时设置的过期时间计算。
**示例:**
```bash
# 方式1:使用时间戳(毫秒)
STANDX_TOKEN_EXPIRY=1735689600000
# 方式2:使用时间戳(秒)
STANDX_TOKEN_EXPIRY=1735689600
# 方式3:使用 ISO 日期字符串
STANDX_TOKEN_EXPIRY=2025-01-01T00:00:00Z
```
> 💡 **提示**:如果不配置此项,策略不会检查 Token 过期时间,但建议配置以便及时收到提醒。
### Telegram 通知配置详解
配置 Telegram 通知后,策略会在以下情况发送通知:
- 📝 **订单成交**:挂单被成交时
- 📈 **开仓**:持仓从 0 变为非 0 时
- 📉 **平仓**:持仓从非 0 变为 0 时
- 🛑 **止损触发**:触发止损平仓时
-**Token 过期**Token 过期时
**配置步骤:**
1. **创建 Telegram 机器人**
- 在 Telegram 搜索 `@BotFather`
- 发送 `/newbot` 命令
- 按提示设置机器人名称和用户名
- 获取 Bot Token(格式类似:`123456789:ABCdefGHIjklMNOpqrsTUVwxyz`
2. **获取你的 Chat ID**
- 在 Telegram 搜索 `@userinfobot`
- 发送任意消息
- 机器人会返回你的 Chat ID(一串数字,例如:`123456789`
3. **配置环境变量**
```bash
TELEGRAM_BOT_TOKEN=123456789:ABCdefGHIjklMNOpqrsTUVwxyz
TELEGRAM_CHAT_ID=123456789
TELEGRAM_ACCOUNT_LABEL=我的账户(可选)
```
4. **测试通知**
- 启动策略后,如果配置正确,会在 Token 过期或重要事件时收到通知
- 如果收不到通知,检查 Bot Token 和 Chat ID 是否正确
> 💡 **提示**`TELEGRAM_ACCOUNT_LABEL` 是可选的,如果你有多个账户在运行策略,可以用这个标签区分不同账户的通知。
---
@@ -233,6 +325,17 @@ bun run pm2:start:maker-points
把 `MAKER_POINTS_CLOSE_THRESHOLD` 设为 `0` 或者设置成一个比你持仓大的数字。
### Q:如何知道 Token 什么时候过期?
配置 `STANDX_TOKEN_EXPIRY` 环境变量,策略会在 Token 过期前 1 小时提醒你。Token 过期后,如果有持仓会进入平仓模式,只允许平仓和止损。
### QTelegram 通知收不到怎么办?
1. 检查 `TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID` 是否正确填写
2. 确保没有在 Bot Token 和 Chat ID 前后加引号或空格
3. 在 Telegram 中先给机器人发送一条消息(任意内容),然后再启动策略
4. 检查网络连接是否正常
---
## 安全提示
+39
View File
@@ -6,6 +6,45 @@
import { resolveExchangeId, type SupportedExchangeId } from "./exchanges/create-adapter";
import { language, type Language } from "./i18n";
export interface StandxTokenConfig {
expiryTimestamp: number | null;
}
function parseTimestamp(value: string | undefined): number | null {
if (!value || !value.trim()) return null;
const trimmed = value.trim();
const asNumber = Number(trimmed);
if (Number.isFinite(asNumber) && asNumber > 0) {
return asNumber < 1e12 ? asNumber * 1000 : asNumber;
}
const asDate = Date.parse(trimmed);
if (Number.isFinite(asDate) && asDate > 0) {
return asDate;
}
return null;
}
export const standxTokenConfig: StandxTokenConfig = {
expiryTimestamp: parseTimestamp(process.env.STANDX_TOKEN_EXPIRY),
};
export function isStandxTokenExpired(): boolean {
const expiry = standxTokenConfig.expiryTimestamp;
if (expiry == null) return false;
return Date.now() >= expiry;
}
export function getStandxTokenExpiryInfo(): { expired: boolean; expiryTimestamp: number | null; remainingMs: number | null } {
const expiry = standxTokenConfig.expiryTimestamp;
if (expiry == null) {
return { expired: false, expiryTimestamp: null, remainingMs: null };
}
const now = Date.now();
const expired = now >= expiry;
const remainingMs = expired ? 0 : expiry - now;
return { expired, expiryTimestamp: expiry, remainingMs };
}
export interface TradingConfig {
symbol: string;
tradeAmount: number;
+12
View File
@@ -0,0 +1,12 @@
export type {
NotificationLevel,
TradeNotification,
NotificationSender,
NotificationConfig,
} from "./types";
export {
TelegramNotifier,
createTelegramNotifier,
type TelegramConfig,
} from "./telegram";
+113
View File
@@ -0,0 +1,113 @@
import type { NotificationSender, TradeNotification, NotificationConfig } from "./types";
export interface TelegramConfig extends NotificationConfig {
botToken: string;
chatId: string;
}
const LEVEL_EMOJI: Record<string, string> = {
info: "️",
warn: "⚠️",
error: "🚨",
success: "✅",
};
const TYPE_EMOJI: Record<string, string> = {
order_filled: "📝",
position_opened: "📈",
position_closed: "📉",
stop_loss: "🛑",
token_expired: "⏰",
custom: "📢",
};
function formatNotificationMessage(notification: TradeNotification, accountLabel?: string): string {
const levelEmoji = LEVEL_EMOJI[notification.level] ?? "";
const typeEmoji = TYPE_EMOJI[notification.type] ?? "";
const timestamp = notification.timestamp ?? Date.now();
const time = new Date(timestamp).toISOString().replace("T", " ").substring(0, 19);
const label = notification.accountLabel ?? accountLabel ?? notification.symbol;
const lines: string[] = [
`${typeEmoji}${levelEmoji} [${label}] ${notification.title}`,
``,
`${notification.message}`,
];
if (notification.details && Object.keys(notification.details).length > 0) {
lines.push(``);
for (const [key, value] of Object.entries(notification.details)) {
if (value != null) {
lines.push(`${key}: ${value}`);
}
}
}
lines.push(``);
lines.push(`🕐 ${time} UTC`);
lines.push(`📊 ${notification.symbol}`);
return lines.join("\n");
}
export class TelegramNotifier implements NotificationSender {
private readonly config: TelegramConfig;
private readonly baseUrl: string;
private sendQueue: Promise<void> = Promise.resolve();
constructor(config: Partial<TelegramConfig> = {}) {
this.config = {
enabled: Boolean(config.botToken && config.chatId),
botToken: config.botToken ?? "",
chatId: config.chatId ?? "",
accountLabel: config.accountLabel,
};
this.baseUrl = `https://api.telegram.org/bot${this.config.botToken}`;
}
isEnabled(): boolean {
return this.config.enabled;
}
async send(notification: TradeNotification): Promise<void> {
if (!this.isEnabled()) {
return;
}
this.sendQueue = this.sendQueue
.then(() => this.doSend(notification))
.catch(() => {});
}
private async doSend(notification: TradeNotification): Promise<void> {
const text = formatNotificationMessage(notification, this.config.accountLabel);
const url = `${this.baseUrl}/sendMessage`;
try {
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chat_id: this.config.chatId,
text,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
console.error(`[Telegram] Failed to send notification: ${response.status} ${errorText}`);
}
} catch (error) {
console.error(`[Telegram] Failed to send notification: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
export function createTelegramNotifier(): TelegramNotifier {
return new TelegramNotifier({
botToken: process.env.TELEGRAM_BOT_TOKEN,
chatId: process.env.TELEGRAM_CHAT_ID,
accountLabel: process.env.TELEGRAM_ACCOUNT_LABEL,
});
}
+22
View File
@@ -0,0 +1,22 @@
export type NotificationLevel = "info" | "warn" | "error" | "success";
export interface TradeNotification {
type: "order_filled" | "position_opened" | "position_closed" | "stop_loss" | "token_expired" | "custom";
level: NotificationLevel;
symbol: string;
title: string;
message: string;
accountLabel?: string;
details?: Record<string, string | number | boolean | null>;
timestamp?: number;
}
export interface NotificationSender {
send(notification: TradeNotification): Promise<void>;
isEnabled(): boolean;
}
export interface NotificationConfig {
enabled: boolean;
accountLabel?: string;
}
+221 -4
View File
@@ -29,6 +29,17 @@ import { SessionVolumeTracker } from "./common/session-volume";
import { BinanceDepthTracker, type BinanceDepthSnapshot } from "./common/binance-depth";
import { buildBpsTargets } from "./maker-points-logic";
import { t } from "../i18n";
import {
checkStandxTokenExpiry,
formatTokenExpiryMessage,
isTokenExpiryConfigured,
type TokenExpiryState,
} from "../utils/standx-token-expiry";
import {
createTelegramNotifier,
type NotificationSender,
type TradeNotification,
} from "../notifications";
interface DesiredOrder {
side: "BUY" | "SELL";
@@ -90,6 +101,7 @@ export class MakerPointsEngine {
private readonly sessionVolume = new SessionVolumeTracker();
private readonly rateLimit: RateLimitController;
private readonly binanceDepth: BinanceDepthTracker;
private readonly notifier: NotificationSender;
private priceTick: number = 0.1;
private qtyStep: number = 0.001;
@@ -128,11 +140,21 @@ export class MakerPointsEngine {
private insufficientBalanceNotified = false;
private lastInsufficientMessage: string | null = null;
private tokenExpiryState: TokenExpiryState = "active";
private tokenExpiryLogged = false;
private tokenExpiryCancelDone = false;
private tokenExpiredCloseOnlyMode = false;
private tokenExpiryNotified = false;
private lastPositionAmt = 0;
private lastPositionSide: "LONG" | "SHORT" | "FLAT" = "FLAT";
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.notifier = createTelegramNotifier();
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), {
@@ -201,6 +223,7 @@ export class MakerPointsEngine {
}
const position = getPosition(snapshot, this.config.symbol);
this.sessionVolume.update(position, this.getReferencePrice());
this.detectPositionChange(position);
this.feedStatus.account = true;
this.emitUpdate();
},
@@ -314,6 +337,14 @@ export class MakerPointsEngine {
return;
}
const position = getPosition(this.accountSnapshot, this.config.symbol);
const absPosition = Math.abs(position.positionAmt);
if (await this.handleTokenExpiry(position, absPosition)) {
this.emitUpdate();
return;
}
const depth = this.depthSnapshot!;
const { topBid, topAsk } = getTopPrices(depth);
if (topBid == null || topAsk == null) {
@@ -321,13 +352,12 @@ export class MakerPointsEngine {
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) &&
this.tokenExpiredCloseOnlyMode ||
(Number.isFinite(closeThreshold) &&
closeThreshold > 0 &&
absPosition >= closeThreshold - EPS;
absPosition >= closeThreshold - EPS);
const prevCloseOnly = this.lastCloseOnly;
if (closeOnly !== prevCloseOnly) {
this.tradeLog.push("info", closeOnly ? "进入平仓模式,仅挂 reduce-only" : "退出平仓模式");
@@ -615,6 +645,19 @@ export class MakerPointsEngine {
"stop",
`触发止损: 未实现亏损 ${position.unrealizedProfit.toFixed(4)} USDT`
);
this.notifier.send({
type: "stop_loss",
level: "error",
symbol: this.config.symbol,
title: "止损触发",
message: `未实现亏损 ${position.unrealizedProfit.toFixed(4)} USDT,强制平仓`,
details: {
side: position.positionAmt > 0 ? "LONG" : "SHORT",
size: absPosition,
unrealizedPnl: position.unrealizedProfit,
lossLimit: -lossLimit,
},
});
try {
await this.flushOrders();
await marketClose(
@@ -828,6 +871,180 @@ export class MakerPointsEngine {
}
return active;
}
private detectPositionChange(position: PositionSnapshot): void {
const currentAmt = position.positionAmt;
const currentSide: "LONG" | "SHORT" | "FLAT" =
currentAmt > EPS ? "LONG" : currentAmt < -EPS ? "SHORT" : "FLAT";
const prevAmt = this.lastPositionAmt;
const prevSide = this.lastPositionSide;
if (Math.abs(currentAmt - prevAmt) < EPS && currentSide === prevSide) {
return;
}
const absChange = Math.abs(currentAmt - prevAmt);
const reference = this.getReferencePrice() ?? 0;
if (prevSide === "FLAT" && currentSide !== "FLAT") {
this.notifier.send({
type: "position_opened",
level: "info",
symbol: this.config.symbol,
title: "开仓",
message: `${currentSide === "LONG" ? "做多" : "做空"} ${Math.abs(currentAmt).toFixed(6)}`,
details: {
side: currentSide,
size: Math.abs(currentAmt),
price: reference > 0 ? reference : null,
},
});
} else if (currentSide === "FLAT" && prevSide !== "FLAT") {
const pnl = position.unrealizedProfit;
const closeType = this.tokenExpiredCloseOnlyMode ? "Token过期平仓" : "平仓";
this.notifier.send({
type: "position_closed",
level: "success",
symbol: this.config.symbol,
title: closeType,
message: `已平仓 ${Math.abs(prevAmt).toFixed(6)} (${prevSide === "LONG" ? "多" : "空"})`,
details: {
prevSide,
closedSize: Math.abs(prevAmt),
pnl: Number.isFinite(pnl) ? pnl : null,
},
});
} else if (currentSide === prevSide && absChange > EPS) {
const isIncrease = Math.abs(currentAmt) > Math.abs(prevAmt);
if (isIncrease) {
this.notifier.send({
type: "order_filled",
level: "info",
symbol: this.config.symbol,
title: "加仓",
message: `${currentSide === "LONG" ? "做多" : "做空"} +${absChange.toFixed(6)}${Math.abs(currentAmt).toFixed(6)}`,
details: {
side: currentSide,
added: absChange,
totalSize: Math.abs(currentAmt),
},
});
} else {
this.notifier.send({
type: "order_filled",
level: "info",
symbol: this.config.symbol,
title: "减仓",
message: `${currentSide === "LONG" ? "多" : "空"} -${absChange.toFixed(6)}${Math.abs(currentAmt).toFixed(6)}`,
details: {
side: currentSide,
reduced: absChange,
totalSize: Math.abs(currentAmt),
},
});
}
} else if (currentSide !== prevSide && currentSide !== "FLAT" && prevSide !== "FLAT") {
this.notifier.send({
type: "position_opened",
level: "info",
symbol: this.config.symbol,
title: "反向开仓",
message: `${prevSide === "LONG" ? "多→空" : "空→多"} ${Math.abs(currentAmt).toFixed(6)}`,
details: {
prevSide,
newSide: currentSide,
size: Math.abs(currentAmt),
},
});
}
this.lastPositionAmt = currentAmt;
this.lastPositionSide = currentSide;
}
private async handleTokenExpiry(position: PositionSnapshot, absPosition: number): Promise<boolean> {
if (!isTokenExpiryConfigured()) {
return false;
}
const expiryStatus = checkStandxTokenExpiry({
positionAmt: position.positionAmt,
openOrderCount: this.openOrders.length,
});
if (!expiryStatus.expired) {
if (this.tokenExpiryState !== "active") {
this.tokenExpiryState = "active";
this.tokenExpiryLogged = false;
this.tokenExpiryCancelDone = false;
this.tokenExpiredCloseOnlyMode = false;
this.tokenExpiryNotified = false;
}
return false;
}
const prevState = this.tokenExpiryState;
this.tokenExpiryState = expiryStatus.state;
if (!this.tokenExpiryLogged) {
const message = formatTokenExpiryMessage(expiryStatus);
if (message) {
this.tradeLog.push("warn", message);
}
this.tokenExpiryLogged = true;
}
if (!this.tokenExpiryNotified) {
this.notifier.send({
type: "token_expired",
level: "warn",
symbol: this.config.symbol,
title: "Token 已过期",
message: expiryStatus.hasPosition
? "Token 已过期,进入平仓模式,不再开新仓"
: "Token 已过期,策略进入静默模式",
details: {
hasPosition: expiryStatus.hasPosition,
hasOpenOrders: expiryStatus.hasOpenOrders,
state: expiryStatus.state,
},
});
this.tokenExpiryNotified = true;
}
if (!this.tokenExpiryCancelDone && this.openOrders.length > 0) {
try {
await this.exchange.cancelAllOrders({ symbol: this.config.symbol });
this.tradeLog.push("order", "Token 过期,已撤销所有挂单");
this.openOrders = [];
this.tokenExpiryCancelDone = true;
} catch (error) {
if (isUnknownOrderError(error)) {
this.tradeLog.push("order", "Token 过期撤单时订单已不存在");
this.tokenExpiryCancelDone = true;
} else {
this.tradeLog.push("error", `Token 过期撤单失败: ${extractMessage(error)}`);
}
}
}
if (expiryStatus.state === "expired_with_position") {
if (!this.tokenExpiredCloseOnlyMode) {
this.tokenExpiredCloseOnlyMode = true;
this.tradeLog.push("info", "Token 过期,强制进入平仓模式,仅允许 reduce-only 订单");
}
return false;
}
if (expiryStatus.state === "silent") {
if (prevState !== "silent") {
this.tradeLog.push("info", "进入静默数据接收模式,不再进行任何交易操作");
}
return true;
}
return true;
}
}
function resolveBinanceSymbol(symbol: string): string {
+92
View File
@@ -0,0 +1,92 @@
import { isStandxTokenExpired, getStandxTokenExpiryInfo, standxTokenConfig } from "../config";
export type TokenExpiryState = "active" | "expired" | "expired_with_position" | "silent";
export interface TokenExpiryStatus {
state: TokenExpiryState;
expired: boolean;
expiryTimestamp: number | null;
remainingMs: number | null;
hasPosition: boolean;
hasOpenOrders: boolean;
}
export interface TokenExpiryCheckParams {
positionAmt: number;
openOrderCount: number;
}
export function checkStandxTokenExpiry(params: TokenExpiryCheckParams): TokenExpiryStatus {
const info = getStandxTokenExpiryInfo();
const hasPosition = Math.abs(params.positionAmt) > 1e-8;
const hasOpenOrders = params.openOrderCount > 0;
if (!info.expired) {
return {
state: "active",
expired: false,
expiryTimestamp: info.expiryTimestamp,
remainingMs: info.remainingMs,
hasPosition,
hasOpenOrders,
};
}
if (hasPosition) {
return {
state: "expired_with_position",
expired: true,
expiryTimestamp: info.expiryTimestamp,
remainingMs: 0,
hasPosition: true,
hasOpenOrders,
};
}
if (!hasOpenOrders) {
return {
state: "silent",
expired: true,
expiryTimestamp: info.expiryTimestamp,
remainingMs: 0,
hasPosition: false,
hasOpenOrders: false,
};
}
return {
state: "expired",
expired: true,
expiryTimestamp: info.expiryTimestamp,
remainingMs: 0,
hasPosition,
hasOpenOrders,
};
}
export function formatTokenExpiryMessage(status: TokenExpiryStatus): string | null {
if (!status.expired) {
if (status.remainingMs != null && status.remainingMs < 3600_000) {
const mins = Math.ceil(status.remainingMs / 60_000);
return `StandX Token 将在 ${mins} 分钟后过期`;
}
return null;
}
switch (status.state) {
case "expired":
return "StandX Token 已过期,正在取消所有挂单";
case "expired_with_position":
return "StandX Token 已过期,仅保留平仓/止损逻辑";
case "silent":
return "StandX Token 已过期,进入静默数据接收模式";
default:
return null;
}
}
export function isTokenExpiryConfigured(): boolean {
return standxTokenConfig.expiryTimestamp != null;
}
export { isStandxTokenExpired, getStandxTokenExpiryInfo };