Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/ton-trading-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
57 changes: 42 additions & 15 deletions plugins/ton-trading-bot/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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}`);
Expand Down
2 changes: 1 addition & 1 deletion plugins/ton-trading-bot/manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
60 changes: 60 additions & 0 deletions plugins/ton-trading-bot/tests/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
73 changes: 73 additions & 0 deletions plugins/ton-trading-bot/tests/open-positions.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading