diff --git a/plugins/ton-trading-bot/README.md b/plugins/ton-trading-bot/README.md index 801bb00..88c8824 100644 --- a/plugins/ton-trading-bot/README.md +++ b/plugins/ton-trading-bot/README.md @@ -257,7 +257,7 @@ plugins: | `mode` | string | No | simulation | "real" or "simulation" | | `trigger_price_below` | number | No | — | Execute when price falls below this value | | `trigger_price_above` | number | No | — | Execute when price rises above this value | -| `auto_close_at_profit_percent` | number | No | — | Auto-register take-profit rule after execution | +| `auto_close_at_profit_percent` | number | No | — | Auto-register profit rule; TON→stablecoin accumulation triggers after this price drop, other pairs after a price rise | | `auto_stop_loss_percent` | number | No | — | Auto-register stop-loss rule after execution | ### `ton_trading_get_portfolio_summary` diff --git a/plugins/ton-trading-bot/index.js b/plugins/ton-trading-bot/index.js index a39b21d..a03dde9 100644 --- a/plugins/ton-trading-bot/index.js +++ b/plugins/ton-trading-bot/index.js @@ -79,7 +79,7 @@ export const manifest = { name: "ton-trading-bot", - version: "2.4.1", + version: "2.4.2", sdkVersion: ">=1.0.0", description: "Atomic TON trading tools: market data, portfolio, risk validation, simulation, DEX swap execution, cross-DEX arbitrage, sniper trading, copy trading, liquidity pools, farming, backtesting, and risk management.", defaultConfig: { @@ -131,7 +131,8 @@ export function migrate(db) { status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'triggered' | 'cancelled' trailing_stop INTEGER NOT NULL DEFAULT 0, -- 1 if trailing stop is active trailing_stop_percent REAL, -- trailing offset below peak price - peak_price REAL -- highest price seen (for trailing stop) + peak_price REAL, -- highest price seen (for trailing stop) + profit_direction TEXT NOT NULL DEFAULT 'above' -- 'above' for long, 'below' for accumulation ); -- Scheduled trades: pending orders for future execution @@ -156,6 +157,7 @@ export function migrate(db) { "ALTER TABLE stop_loss_rules ADD COLUMN trailing_stop INTEGER NOT NULL DEFAULT 0", "ALTER TABLE stop_loss_rules ADD COLUMN trailing_stop_percent REAL", "ALTER TABLE stop_loss_rules ADD COLUMN peak_price REAL", + "ALTER TABLE stop_loss_rules ADD COLUMN profit_direction TEXT NOT NULL DEFAULT 'above'", // Links an executed scheduled trade to the trade_journal row it produced, so // a DCA/grid order can never be silently re-executed (issue #182). "ALTER TABLE scheduled_trades ADD COLUMN trade_id INTEGER", @@ -329,7 +331,7 @@ async function inferExitPriceUsd(sdk, fromAsset, explicitExitPriceUsd) { return inferAssetPriceUsd(sdk, fromAsset); } -function closeTradeJournalEntry(sdk, entry, amountOut, exitPriceUsd, note) { +function closeTradeJournalEntry(sdk, entry, amountOut, exitPriceUsd, note, options = {}) { const amountIn = toFiniteNumber(entry.amount_in) ?? 0; const amountOutNumber = toFiniteNumber(amountOut); const entryPriceUsd = toFiniteNumber(entry.entry_price_usd); @@ -339,14 +341,20 @@ function closeTradeJournalEntry(sdk, entry, amountOut, exitPriceUsd, note) { return { success: false, error: "Closing amount_out is required to record P&L" }; } - // P&L must be computed from two values in the SAME unit (issue #182). We never + // P&L must be computed from two values in the SAME unit (issues #182, #204). + // A completed reverse swap is a round trip, so amount_out is back in + // from_asset units and token accumulation is the authoritative result. + // Manual journal closes retain the price-based mode for compatibility when + // they do not prove that the supplied amount_out came from a reverse swap. + // We never // mix a USD-priced leg with a raw-token leg — that produced nonsense such as // +$7.39 / +73.89% on a flat TON/USDT trade. Two unit-safe modes: // 1. Price-based USD P&L — needs BOTH the entry and exit USD price of the // base (from_asset): pnl = base_amount * (exit_price - entry_price). - // 2. Raw same-unit diff — backward-compatible fallback when prices are - // unavailable; only meaningful when amount_in and amount_out share a unit. - const priceBased = entryPriceUsd != null && exitPrice != null; + // 2. Raw same-unit diff — used for a confirmed round trip, or as the + // backward-compatible fallback when prices are unavailable. + const roundTrip = options.roundTrip === true; + const priceBased = !roundTrip && entryPriceUsd != null && exitPrice != null; let pnl; let pnlPercent; if (priceBased) { @@ -399,6 +407,7 @@ function closeTradeJournalEntry(sdk, entry, amountOut, exitPriceUsd, note) { amount_out: amountOutNumber, pnl: parseFloat(pnl.toFixed(6)), pnl_percent: parseFloat(pnlPercent.toFixed(2)), + pnl_asset: roundTrip ? entry.from_asset : (priceBased ? "USD" : entry.from_asset), profit_or_loss: pnl >= 0 ? "profit" : "loss", mode: entry.mode, status: "closed", @@ -694,7 +703,8 @@ async function closeOpenPosition(sdk, entry, params, context) { entry, closeAmountOut, exitPriceUsd, - note ?? "closed by ton_trading_close_position" + note ?? "closed by ton_trading_close_position", + { roundTrip: true } ); if (!closeResult.success) return closeResult; @@ -2553,8 +2563,13 @@ export const tools = (sdk) => [ // The plain stop-loss level is always based on entry price, regardless of trailing. const plainStopLossPrice = rule.entry_price * (1 - rule.stop_loss_percent / 100); + const profitDirection = rule.profit_direction === "below" ? "below" : "above"; const takeProfitPrice = rule.take_profit_percent != null - ? rule.entry_price * (1 + rule.take_profit_percent / 100) + ? rule.entry_price * ( + profitDirection === "below" + ? 1 - rule.take_profit_percent / 100 + : 1 + rule.take_profit_percent / 100 + ) : null; // When trailing stop is active the effective stop price is the trailing floor @@ -2568,7 +2583,11 @@ export const tools = (sdk) => [ // For plain rules: take_profit fires when price reaches the static target. const takeProfitHit = rule.trailing_stop ? trailingStopPrice != null && current_price <= trailingStopPrice && !stopLossHit - : takeProfitPrice != null && current_price >= takeProfitPrice; + : takeProfitPrice != null && ( + profitDirection === "below" + ? current_price <= takeProfitPrice + : current_price >= takeProfitPrice + ); const annotated = { rule_id: rule.id, @@ -2579,6 +2598,7 @@ export const tools = (sdk) => [ take_profit_price: takeProfitPrice != null ? parseFloat(takeProfitPrice.toFixed(6)) : null, stop_loss_percent: rule.stop_loss_percent, take_profit_percent: rule.take_profit_percent ?? null, + profit_direction: profitDirection, stop_loss_hit: stopLossHit, take_profit_hit: takeProfitHit, trailing_stop: rule.trailing_stop === 1, @@ -3245,7 +3265,7 @@ export const tools = (sdk) => [ }, auto_close_at_profit_percent: { type: "number", - description: "Automatically register a take-profit rule after execution at this profit %", + description: "Automatically register a profit rule. For TON→stablecoin accumulation it triggers after this price drop; otherwise after a price rise.", minimum: 0.1, }, auto_stop_loss_percent: { @@ -3409,14 +3429,21 @@ export const tools = (sdk) => [ if (auto_close_at_profit_percent != null || auto_stop_loss_percent != null) { const stopLossPct = auto_stop_loss_percent ?? 99; const tpPct = auto_close_at_profit_percent ?? null; + const profitDirection = from_asset === "TON" && isStablecoin(to_asset) ? "below" : "above"; const ruleId = sdk.db .prepare( - `INSERT INTO stop_loss_rules (trade_id, stop_loss_percent, take_profit_percent, entry_price, created_at) - VALUES (?, ?, ?, ?, ?)` + `INSERT INTO stop_loss_rules + (trade_id, stop_loss_percent, take_profit_percent, entry_price, created_at, profit_direction) + VALUES (?, ?, ?, ?, ?, ?)` ) - .run(tradeResult.trade_id, stopLossPct, tpPct, currentPrice ?? amount, Date.now()) + .run(tradeResult.trade_id, stopLossPct, tpPct, currentPrice ?? amount, Date.now(), profitDirection) .lastInsertRowid; - rules.push({ rule_id: ruleId, stop_loss_percent: stopLossPct, take_profit_percent: tpPct }); + rules.push({ + rule_id: ruleId, + stop_loss_percent: stopLossPct, + take_profit_percent: tpPct, + profit_direction: profitDirection, + }); } sdk.log.info(`Auto-executed trade #${tradeResult.trade_id}: ${amount} ${from_asset} → ${to_asset}`); diff --git a/plugins/ton-trading-bot/manifest.json b/plugins/ton-trading-bot/manifest.json index dced185..7108c5b 100644 --- a/plugins/ton-trading-bot/manifest.json +++ b/plugins/ton-trading-bot/manifest.json @@ -1,7 +1,7 @@ { "id": "ton-trading-bot", "name": "TON Trading Bot", - "version": "2.4.1", + "version": "2.4.2", "description": "Atomic TON trading tools: market data, portfolio, risk validation, simulation, DEX swap execution, cross-DEX arbitrage, sniper trading, copy trading, liquidity pools, farming, backtesting, risk management, and automation.", "author": { "name": "xlabtg", diff --git a/plugins/ton-trading-bot/tests/index.test.js b/plugins/ton-trading-bot/tests/index.test.js index 41188c9..50b36d0 100644 --- a/plugins/ton-trading-bot/tests/index.test.js +++ b/plugins/ton-trading-bot/tests/index.test.js @@ -2165,6 +2165,34 @@ describe("ton-trading-bot plugin", () => { // ── ton_trading_check_stop_loss ──────────────────────────────────────────── describe("ton_trading_check_stop_loss", () => { + it("triggers an accumulation take-profit on a price drop, not a rise (issue #204)", async () => { + const rule = { + id: 204, + trade_id: 188, + entry_price: 1.795, + stop_loss_percent: 99, + take_profit_percent: 3, + profit_direction: "below", + trailing_stop: 0, + trailing_stop_percent: null, + peak_price: null, + }; + const sdk = makeSdk({ dbRows: { stopLossRules: [rule] } }); + const basePrepare = sdk.db.prepare.bind(sdk.db); + sdk.db.prepare = (sql) => sql.includes("FROM stop_loss_rules") + ? { all: () => [rule] } + : basePrepare(sql); + const tool = mod.tools(sdk).find((t) => t.name === "ton_trading_check_stop_loss"); + + const rise = await tool.execute({ current_price: 1.85, trade_id: 188 }, makeContext()); + assert.equal(rise.data.triggered_rules.length, 0); + + const drop = await tool.execute({ current_price: 1.74, trade_id: 188 }, makeContext()); + assert.equal(drop.data.triggered_rules.length, 1); + assert.equal(drop.data.triggered_rules[0].action, "take_profit"); + assert.equal(drop.data.triggered_rules[0].profit_direction, "below"); + }); + it("required parameters include current_price", () => { const sdk = makeSdk(); const tool = mod.tools(sdk).find((t) => t.name === "ton_trading_check_stop_loss"); @@ -3220,6 +3248,38 @@ describe("ton-trading-bot plugin", () => { // ── ton_trading_auto_execute ──────────────────────────────────────────────── describe("ton_trading_auto_execute", () => { + it("registers TON→USDT accumulation profit below the entry price (issue #204)", async () => { + let ruleValues = null; + const sdk = makeSdk({ dbRows: { lastInsertRowid: 10, simBalance: { balance: 500 } } }); + const basePrepare = sdk.db.prepare.bind(sdk.db); + sdk.db.prepare = (sql) => { + if (sql.includes("INSERT INTO stop_loss_rules")) { + return { + run: (...args) => { + ruleValues = args; + return { lastInsertRowid: 91, changes: 1 }; + }, + }; + } + return basePrepare(sql); + }; + const tool = mod.tools(sdk).find((t) => t.name === "ton_trading_auto_execute"); + const result = await tool.execute( + { + from_asset: "TON", + to_asset: "USDT", + amount: 5, + mode: "simulation", + auto_close_at_profit_percent: 3, + }, + makeContext() + ); + + assert.equal(result.success, true); + assert.equal(result.data.risk_rules[0].profit_direction, "below"); + assert.equal(ruleValues[5], "below"); + }); + it("executes simulation trade when no trigger conditions set", async () => { const sdk = makeSdk({ dbRows: { lastInsertRowid: 10, simBalance: { balance: 500 } } }); const tool = mod.tools(sdk).find((t) => t.name === "ton_trading_auto_execute"); diff --git a/plugins/ton-trading-bot/tests/open-positions.test.js b/plugins/ton-trading-bot/tests/open-positions.test.js index e5df273..371f183 100644 --- a/plugins/ton-trading-bot/tests/open-positions.test.js +++ b/plugins/ton-trading-bot/tests/open-positions.test.js @@ -229,6 +229,39 @@ describe("ton-trading-bot position-management API (issue #188)", () => { }); describe("ton_trading_close_position", () => { + it("scores a TON→USDT accumulation close by TON received, not by TON/USD movement (issue #204)", async () => { + const db = makeStatefulDb([ + openTrade({ + id: 188, + mode: "simulation", + from_asset: "TON", + to_asset: "USDT", + amount_in: 10, + amount_out: 17.95, + entry_price_usd: 1.795, + }), + ]); + const sdk = makeSdk({ + db, + ton: { + ...makeSdk().ton, + getPrice: async () => ({ usd: 1.6412, source: "mock", timestamp: 1 }), + dex: { + quote: async () => ({ stonfi: { output: "10.93" }, recommended: "stonfi" }), + swap: async () => null, + }, + }, + }); + const tool = findTool(sdk, "ton_trading_close_position"); + const result = await tool.execute({ trade_id: 188, mode: "simulation" }, makeContext()); + + assert.equal(result.success, true); + assert.equal(result.data.pnl, 0.93); + assert.equal(result.data.pnl_percent, 9.3); + assert.equal(result.data.pnl_asset, "TON"); + assert.equal(result.data.profit_or_loss, "profit"); + }); + it("transitions a simulation position from 'open' to 'closed'", async () => { const db = makeStatefulDb([openTrade({ id: 7, mode: "simulation" })]); const tool = findTool(makeSdk({ db }), "ton_trading_close_position"); @@ -256,6 +289,46 @@ describe("ton-trading-bot position-management API (issue #188)", () => { assert.equal(result.success, false); assert.match(result.error, /not found/i); }); + + it("returns a simulation position to open when the reverse quote fails", async () => { + const db = makeStatefulDb([openTrade({ id: 9, mode: "simulation" })]); + const sdk = makeSdk({ + db, + ton: { + ...makeSdk().ton, + dex: { + quote: async () => { throw new Error("quote unavailable"); }, + swap: async () => null, + }, + }, + }); + const tool = findTool(sdk, "ton_trading_close_position"); + const result = await tool.execute({ trade_id: 9, mode: "simulation" }, makeContext()); + + assert.equal(result.success, false); + assert.match(result.error, /quote unavailable/); + assert.equal(db._trades.find((t) => t.id === 9).status, "open"); + }); + + it("marks a real position close_failed when reverse-swap state is unknown", async () => { + const db = makeStatefulDb([openTrade({ id: 13, mode: "real" })]); + const sdk = makeSdk({ + db, + ton: { + ...makeSdk().ton, + dex: { + quote: async () => null, + swap: async () => { throw new Error("network lost after submit"); }, + }, + }, + }); + const tool = findTool(sdk, "ton_trading_close_position"); + const result = await tool.execute({ trade_id: 13, mode: "real" }, makeContext()); + + assert.equal(result.success, false); + assert.match(result.error, /network lost after submit/); + assert.equal(db._trades.find((t) => t.id === 13).status, "close_failed"); + }); }); describe("ton_trading_close_all_positions", () => {