diff --git a/.env.example b/.env.example index c9f0daa..33cadfc 100644 --- a/.env.example +++ b/.env.example @@ -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") diff --git a/docs/standx/maker-points-guide.md b/docs/standx/maker-points-guide.md index cc05963..61b4089 100644 --- a/docs/standx/maker-points-guide.md +++ b/docs/standx/maker-points-guide.md @@ -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 过期后,如果有持仓会进入平仓模式,只允许平仓和止损。 + +### Q:Telegram 通知收不到怎么办? + +1. 检查 `TELEGRAM_BOT_TOKEN` 和 `TELEGRAM_CHAT_ID` 是否正确填写 +2. 确保没有在 Bot Token 和 Chat ID 前后加引号或空格 +3. 在 Telegram 中先给机器人发送一条消息(任意内容),然后再启动策略 +4. 检查网络连接是否正常 + --- ## 安全提示 diff --git a/src/config.ts b/src/config.ts index db5ec6d..6cd3429 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; diff --git a/src/notifications/index.ts b/src/notifications/index.ts new file mode 100644 index 0000000..8c920d4 --- /dev/null +++ b/src/notifications/index.ts @@ -0,0 +1,12 @@ +export type { + NotificationLevel, + TradeNotification, + NotificationSender, + NotificationConfig, +} from "./types"; + +export { + TelegramNotifier, + createTelegramNotifier, + type TelegramConfig, +} from "./telegram"; diff --git a/src/notifications/telegram.ts b/src/notifications/telegram.ts new file mode 100644 index 0000000..88a00e2 --- /dev/null +++ b/src/notifications/telegram.ts @@ -0,0 +1,113 @@ +import type { NotificationSender, TradeNotification, NotificationConfig } from "./types"; + +export interface TelegramConfig extends NotificationConfig { + botToken: string; + chatId: string; +} + +const LEVEL_EMOJI: Record = { + info: "ℹ️", + warn: "⚠️", + error: "🚨", + success: "✅", +}; + +const TYPE_EMOJI: Record = { + 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 = Promise.resolve(); + + constructor(config: Partial = {}) { + 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 { + if (!this.isEnabled()) { + return; + } + + this.sendQueue = this.sendQueue + .then(() => this.doSend(notification)) + .catch(() => {}); + } + + private async doSend(notification: TradeNotification): Promise { + 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, + }); +} diff --git a/src/notifications/types.ts b/src/notifications/types.ts new file mode 100644 index 0000000..723b7c8 --- /dev/null +++ b/src/notifications/types.ts @@ -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; + timestamp?: number; +} + +export interface NotificationSender { + send(notification: TradeNotification): Promise; + isEnabled(): boolean; +} + +export interface NotificationConfig { + enabled: boolean; + accountLabel?: string; +} diff --git a/src/strategy/maker-points-engine.ts b/src/strategy/maker-points-engine.ts index 70d8ab1..1f18307 100644 --- a/src/strategy/maker-points-engine.ts +++ b/src/strategy/maker-points-engine.ts @@ -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 { + 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 { diff --git a/src/utils/standx-token-expiry.ts b/src/utils/standx-token-expiry.ts new file mode 100644 index 0000000..ba0301c --- /dev/null +++ b/src/utils/standx-token-expiry.ts @@ -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 };