@aicoin/aicoin-mcp 1.1.1 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { readFileSync } from "fs";
5
- import { fileURLToPath as fileURLToPath2 } from "url";
6
- import { dirname as dirname2, resolve } from "path";
4
+ import { readFileSync as readFileSync2 } from "fs";
5
+ import { fileURLToPath as fileURLToPath3 } from "url";
6
+ import { dirname as dirname3, resolve as resolve3 } from "path";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
9
 
@@ -44,12 +44,12 @@ var BROKER_CONFIG = {
44
44
  headers: {}
45
45
  },
46
46
  bybit: {
47
- options: {},
48
- headers: { Referer: "AiCoin" }
47
+ options: { brokerId: "AiCoin" },
48
+ headers: {}
49
49
  },
50
50
  bitget: {
51
- options: {},
52
- headers: { "X-CHANNEL-API-CODE": "tpequ" }
51
+ options: { broker: "tpequ" },
52
+ headers: {}
53
53
  },
54
54
  gate: {
55
55
  options: {},
@@ -600,7 +600,7 @@ function registerTradeTools(server2) {
600
600
  );
601
601
  server2.tool(
602
602
  "create_order",
603
- "Place a new order (market or limit). For conditional SL/TP orders, use stop_loss_price or take_profit_price with type=market.",
603
+ "Place a new order (market or limit). Default returns a PREVIEW (confirmed=false). Set confirmed=true to actually execute. For conditional SL/TP orders, use stop_loss_price or take_profit_price with type=market.",
604
604
  {
605
605
  exchange: z2.string().describe("Exchange ID"),
606
606
  symbol: z2.string().describe("Trading pair, e.g. BTC/USDT"),
@@ -608,6 +608,7 @@ function registerTradeTools(server2) {
608
608
  side: z2.enum(["buy", "sell"]).describe("Order side"),
609
609
  amount: z2.number().positive().describe("Order amount"),
610
610
  price: z2.number().positive().optional().describe("Limit price (required for limit orders)"),
611
+ confirmed: z2.boolean().optional().default(false).describe("false (default) = preview only, true = execute order"),
611
612
  pos_side: z2.enum(["long", "short"]).optional().describe("Position side for hedge mode: long/short"),
612
613
  margin_mode: z2.enum(["cross", "isolated"]).optional().describe("Margin mode for derivatives: cross/isolated"),
613
614
  stop_loss_price: z2.number().positive().optional().describe("Stop-loss trigger price (creates conditional market order)"),
@@ -615,11 +616,47 @@ function registerTradeTools(server2) {
615
616
  reduce_only: z2.boolean().optional().describe("Reduce-only flag for closing positions (SL/TP orders)"),
616
617
  market_type: marketTypePrivateSchema
617
618
  },
618
- async ({ exchange, symbol, type, side, amount, price, pos_side, margin_mode, stop_loss_price, take_profit_price, reduce_only, market_type }) => {
619
+ async ({ exchange, symbol, type, side, amount, price, confirmed, pos_side, margin_mode, stop_loss_price, take_profit_price, reduce_only, market_type }) => {
619
620
  try {
620
621
  if (type === "limit" && price == null) {
621
622
  return err("price is required for limit orders");
622
623
  }
624
+ if (!confirmed) {
625
+ const pub = getExchange(exchange, market_type, { skipAuth: true });
626
+ await pub.loadMarkets();
627
+ const mkt = pub.markets[symbol];
628
+ if (!mkt) return err(`Symbol '${symbol}' not found on ${exchange}`);
629
+ const ticker = await pub.fetchTicker(symbol);
630
+ const currentPrice = ticker.last ?? ticker.close ?? 0;
631
+ const orderPrice = type === "limit" ? price : currentPrice;
632
+ const contractSize = mkt.contractSize ?? 1;
633
+ const isDerivative = market_type && market_type !== "spot";
634
+ const amountInBase = isDerivative ? amount * contractSize : amount;
635
+ const notional = amountInBase * orderPrice;
636
+ const preview = {
637
+ _preview: true,
638
+ _confirm_hint: "Set confirmed=true to execute this order",
639
+ exchange,
640
+ symbol,
641
+ type,
642
+ side,
643
+ amount,
644
+ price: orderPrice,
645
+ current_price: currentPrice,
646
+ notional_value: `${notional.toFixed(2)} ${mkt.quote}`
647
+ };
648
+ if (isDerivative && contractSize !== 1) {
649
+ preview.contract_size = contractSize;
650
+ preview.amount_in_base = `${amountInBase} ${mkt.base}`;
651
+ preview.unit = `${amount} contracts \xD7 ${contractSize} ${mkt.base}/contract = ${amountInBase} ${mkt.base}`;
652
+ }
653
+ if (mkt.limits?.amount?.min) preview.min_amount = mkt.limits.amount.min;
654
+ if (stop_loss_price) preview.stop_loss_price = stop_loss_price;
655
+ if (take_profit_price) preview.take_profit_price = take_profit_price;
656
+ if (pos_side) preview.pos_side = pos_side;
657
+ if (reduce_only) preview.reduce_only = true;
658
+ return ok(preview);
659
+ }
623
660
  const ex = getExchange(exchange, market_type);
624
661
  const params = {};
625
662
  const isBinance = exchange?.toLowerCase().startsWith("binance");
@@ -3449,6 +3486,257 @@ function registerFreqtradeDevTools(server2) {
3449
3486
  );
3450
3487
  }
3451
3488
 
3489
+ // src/tools/ft-deploy.ts
3490
+ import { z as z11 } from "zod";
3491
+ import { execSync } from "child_process";
3492
+ import { fileURLToPath as fileURLToPath2 } from "url";
3493
+ import { dirname as dirname2, resolve } from "path";
3494
+ var __dir = dirname2(fileURLToPath2(import.meta.url));
3495
+ var SCRIPT = resolve(__dir, "..", "scripts", "ft-deploy.mjs");
3496
+ function registerFtDeployTools(server2) {
3497
+ server2.tool(
3498
+ "ft_deploy",
3499
+ "Freqtrade one-click deployment & management.\n\u2022 check \u2014 verify Python, git, exchange keys, running status\n\u2022 deploy \u2014 full setup (clone, install, config, start). Params: dry_run, strategy, pairs, trading_mode\n\u2022 update \u2014 update Freqtrade via setup.sh\n\u2022 status \u2014 check if running, show last logs\n\u2022 start \u2014 start Freqtrade. Params: strategy\n\u2022 stop \u2014 stop Freqtrade\n\u2022 logs \u2014 view recent logs. Params: lines\n\u2022 backtest \u2014 run strategy backtest. Params: strategy, timeframe, timerange\n\u2022 download_data \u2014 download OHLCV data. Params: timeframe, timerange\n\u2022 remove \u2014 stop and remove (config preserved)",
3500
+ {
3501
+ action: z11.enum([
3502
+ "check",
3503
+ "deploy",
3504
+ "update",
3505
+ "status",
3506
+ "start",
3507
+ "stop",
3508
+ "logs",
3509
+ "backtest",
3510
+ "download_data",
3511
+ "remove"
3512
+ ]).describe("Action to perform"),
3513
+ strategy: z11.string().optional().describe("Strategy name (default: SampleStrategy)"),
3514
+ timeframe: z11.string().optional().describe("For backtest/download_data: candle timeframe, e.g. 1h, 5m"),
3515
+ timerange: z11.string().optional().describe("For backtest/download_data: date range, e.g. 20240101-20240601"),
3516
+ dry_run: z11.boolean().optional().describe("For deploy: true=paper trading (default), false=live"),
3517
+ pairs: z11.array(z11.string()).optional().describe('For deploy: trading pairs, e.g. ["BTC/USDT:USDT"]'),
3518
+ trading_mode: z11.enum(["futures", "spot"]).optional().describe("For deploy: trading mode (default: futures)"),
3519
+ lines: z11.number().optional().describe("For logs: number of lines to show (default: 50)")
3520
+ },
3521
+ async ({ action, strategy, timeframe, timerange, dry_run, pairs, trading_mode, lines }) => {
3522
+ try {
3523
+ const params = {};
3524
+ if (strategy !== void 0) params.strategy = strategy;
3525
+ if (timeframe !== void 0) params.timeframe = timeframe;
3526
+ if (timerange !== void 0) params.timerange = timerange;
3527
+ if (dry_run !== void 0) params.dry_run = dry_run;
3528
+ if (pairs !== void 0) params.pairs = pairs;
3529
+ if (trading_mode !== void 0) params.trading_mode = trading_mode;
3530
+ if (lines !== void 0) params.lines = lines;
3531
+ const paramsArg = Object.keys(params).length > 0 ? ` '${JSON.stringify(params)}'` : "";
3532
+ const output = execSync(
3533
+ `node ${SCRIPT} ${action}${paramsArg}`,
3534
+ { encoding: "utf-8", timeout: 6e5 }
3535
+ ).trim();
3536
+ try {
3537
+ return ok(JSON.parse(output));
3538
+ } catch {
3539
+ return ok({ output });
3540
+ }
3541
+ } catch (e) {
3542
+ return err(e);
3543
+ }
3544
+ }
3545
+ );
3546
+ }
3547
+
3548
+ // src/tools/auto-trade.ts
3549
+ import { z as z12 } from "zod";
3550
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
3551
+ import { resolve as resolve2 } from "path";
3552
+ var CONFIG_DIR = resolve2(process.env.HOME || "", ".aicoin-mcp");
3553
+ var CONFIG_PATH = resolve2(CONFIG_DIR, "trade-config.json");
3554
+ var DEFAULT_CONFIG = {
3555
+ exchange: "okx",
3556
+ symbol: "BTC/USDT:USDT",
3557
+ market_type: "swap",
3558
+ capital_pct: 0.5,
3559
+ leverage: 20,
3560
+ stop_loss_pct: 0.025,
3561
+ take_profit_pct: 0.05
3562
+ };
3563
+ function loadConfig() {
3564
+ if (existsSync(CONFIG_PATH)) {
3565
+ try {
3566
+ return { ...DEFAULT_CONFIG, ...JSON.parse(readFileSync(CONFIG_PATH, "utf-8")) };
3567
+ } catch {
3568
+ }
3569
+ }
3570
+ return { ...DEFAULT_CONFIG };
3571
+ }
3572
+ function saveConfig(cfg) {
3573
+ mkdirSync(CONFIG_DIR, { recursive: true });
3574
+ writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
3575
+ }
3576
+ function registerAutoTradeTools(server2) {
3577
+ server2.tool(
3578
+ "auto_trade",
3579
+ "Automated trading with risk management. AI agent decides strategy, this tool executes.\n\u2022 setup \u2014 save trading config (exchange, symbol, leverage, SL/TP%)\n\u2022 status \u2014 show config + balance + positions + open orders\n\u2022 open \u2014 execute trade: calculate position \u2192 set leverage \u2192 market order \u2192 SL/TP. Requires: direction (long/short)\n\u2022 close \u2014 cancel open orders \u2192 close position",
3580
+ {
3581
+ action: z12.enum(["setup", "status", "open", "close"]).describe(
3582
+ "setup: save config; status: show state; open: execute trade; close: close position"
3583
+ ),
3584
+ direction: z12.enum(["long", "short"]).optional().describe("REQUIRED for open. Trade direction decided by AI agent"),
3585
+ exchange: z12.string().optional().describe("Exchange ID override"),
3586
+ symbol: z12.string().optional().describe("Trading pair override, e.g. BTC/USDT:USDT"),
3587
+ market_type: z12.enum(["spot", "future", "swap"]).optional().describe("Market type override"),
3588
+ capital_pct: z12.number().min(0.01).max(1).optional().describe("Capital allocation ratio (0.01-1.0)"),
3589
+ leverage: z12.number().int().min(1).max(125).optional().describe("Leverage multiplier"),
3590
+ stop_loss_pct: z12.number().min(1e-3).max(0.5).optional().describe("Stop-loss percentage (e.g. 0.025 = 2.5%)"),
3591
+ take_profit_pct: z12.number().min(1e-3).max(1).optional().describe("Take-profit percentage (e.g. 0.05 = 5%)")
3592
+ },
3593
+ async ({ action, direction, exchange, symbol, market_type, capital_pct, leverage, stop_loss_pct, take_profit_pct }) => {
3594
+ try {
3595
+ const overrides = {};
3596
+ if (exchange !== void 0) overrides.exchange = exchange;
3597
+ if (symbol !== void 0) overrides.symbol = symbol;
3598
+ if (market_type !== void 0) overrides.market_type = market_type;
3599
+ if (capital_pct !== void 0) overrides.capital_pct = capital_pct;
3600
+ if (leverage !== void 0) overrides.leverage = leverage;
3601
+ if (stop_loss_pct !== void 0) overrides.stop_loss_pct = stop_loss_pct;
3602
+ if (take_profit_pct !== void 0) overrides.take_profit_pct = take_profit_pct;
3603
+ const cfg = { ...loadConfig(), ...overrides };
3604
+ switch (action) {
3605
+ case "setup": {
3606
+ saveConfig(cfg);
3607
+ return ok({ saved: CONFIG_PATH, config: cfg });
3608
+ }
3609
+ case "status": {
3610
+ const result = { config: cfg };
3611
+ const ex = getExchange(cfg.exchange, cfg.market_type);
3612
+ try {
3613
+ const bal = await ex.fetchBalance();
3614
+ const stablecoins = ["USDT", "USDC", "BUSD", "DAI", "TUSD", "FDUSD"];
3615
+ const summary = {};
3616
+ for (const [ccy, amt] of Object.entries(bal.total || {})) {
3617
+ const total = Number(amt);
3618
+ if (total <= 0) continue;
3619
+ if (stablecoins.includes(ccy) && total < 0.01) continue;
3620
+ if (!stablecoins.includes(ccy) && total < 1e-7) continue;
3621
+ summary[ccy] = { free: bal.free?.[ccy], used: bal.used?.[ccy], total: bal.total?.[ccy] };
3622
+ }
3623
+ result.balance = summary;
3624
+ } catch (e) {
3625
+ result.balance = { error: e instanceof Error ? e.message : String(e) };
3626
+ }
3627
+ try {
3628
+ result.positions = await ex.fetchPositions([cfg.symbol]);
3629
+ } catch (e) {
3630
+ result.positions = { error: e instanceof Error ? e.message : String(e) };
3631
+ }
3632
+ try {
3633
+ result.open_orders = await ex.fetchOpenOrders(cfg.symbol);
3634
+ } catch (e) {
3635
+ result.open_orders = { error: e instanceof Error ? e.message : String(e) };
3636
+ }
3637
+ return ok(result);
3638
+ }
3639
+ case "open": {
3640
+ if (!direction || !["long", "short"].includes(direction)) {
3641
+ return err('Missing "direction": must be "long" or "short"');
3642
+ }
3643
+ const ex = getExchange(cfg.exchange, cfg.market_type);
3644
+ const bal = await ex.fetchBalance();
3645
+ const quote = cfg.symbol.split("/")[1]?.split(":")[0] || "USDT";
3646
+ const available = Number(bal.free?.[quote] || 0);
3647
+ if (available < 1) return err(`Insufficient ${quote} balance: ${available}`);
3648
+ const ticker = await ex.fetchTicker(cfg.symbol);
3649
+ const price = ticker.last ?? ticker.close ?? 0;
3650
+ if (!price) return err("Could not fetch current price");
3651
+ await ex.loadMarkets();
3652
+ const mkt = ex.markets[cfg.symbol];
3653
+ if (!mkt) return err(`Symbol '${cfg.symbol}' not found on ${cfg.exchange}`);
3654
+ const contractSize = mkt.contractSize || 1;
3655
+ const amountStep = mkt.precision?.amount || 0.01;
3656
+ const amountMin = mkt.limits?.amount?.min || amountStep;
3657
+ const capital = available * cfg.capital_pct;
3658
+ const positionValue = capital * cfg.leverage;
3659
+ const amountInBase = positionValue / price;
3660
+ const rawAmount = cfg.market_type !== "spot" && contractSize ? amountInBase / contractSize : amountInBase;
3661
+ const amount = Math.max(Math.floor(rawAmount / amountStep) * amountStep, amountMin);
3662
+ if (amount * contractSize * price < 1) {
3663
+ return err(`Position too small: ${amount} contracts \u2248 ${(amount * contractSize).toFixed(6)} ${mkt.base}`);
3664
+ }
3665
+ try {
3666
+ await ex.setLeverage(cfg.leverage, cfg.symbol);
3667
+ } catch {
3668
+ }
3669
+ const side = direction === "long" ? "buy" : "sell";
3670
+ const order = await ex.createOrder(cfg.symbol, "market", side, amount);
3671
+ const slPrice = direction === "long" ? price * (1 - cfg.stop_loss_pct) : price * (1 + cfg.stop_loss_pct);
3672
+ const tpPrice = direction === "long" ? price * (1 + cfg.take_profit_pct) : price * (1 - cfg.take_profit_pct);
3673
+ const closeSide = direction === "long" ? "sell" : "buy";
3674
+ let sl, tp;
3675
+ try {
3676
+ sl = await ex.createOrder(cfg.symbol, "market", closeSide, amount, void 0, {
3677
+ stopLossPrice: Number(slPrice.toPrecision(6)),
3678
+ reduceOnly: true
3679
+ });
3680
+ } catch (e) {
3681
+ sl = { error: e instanceof Error ? e.message : String(e) };
3682
+ }
3683
+ try {
3684
+ tp = await ex.createOrder(cfg.symbol, "market", closeSide, amount, void 0, {
3685
+ takeProfitPrice: Number(tpPrice.toPrecision(6)),
3686
+ reduceOnly: true
3687
+ });
3688
+ } catch (e) {
3689
+ tp = { error: e instanceof Error ? e.message : String(e) };
3690
+ }
3691
+ return ok({
3692
+ direction,
3693
+ amount,
3694
+ amount_base: `${Number((amount * contractSize).toPrecision(4))} ${mkt.base}`,
3695
+ contract_size: contractSize !== 1 ? `1 contract = ${contractSize} ${mkt.base}` : null,
3696
+ entry_price: price,
3697
+ stop_loss: Number(slPrice.toPrecision(6)),
3698
+ take_profit: Number(tpPrice.toPrecision(6)),
3699
+ order_id: order.id,
3700
+ sl_order: sl?.id ?? sl?.error,
3701
+ tp_order: tp?.id ?? tp?.error,
3702
+ capital_used: capital.toFixed(2),
3703
+ position_value: positionValue.toFixed(2)
3704
+ });
3705
+ }
3706
+ case "close": {
3707
+ const ex = getExchange(cfg.exchange, cfg.market_type);
3708
+ try {
3709
+ if (ex.has["cancelAllOrders"]) {
3710
+ await ex.cancelAllOrders(cfg.symbol);
3711
+ } else {
3712
+ const open = await ex.fetchOpenOrders(cfg.symbol);
3713
+ await Promise.allSettled(open.map((o) => ex.cancelOrder(o.id, cfg.symbol)));
3714
+ }
3715
+ } catch {
3716
+ }
3717
+ const positions = await ex.fetchPositions([cfg.symbol]);
3718
+ const pos = positions.find(
3719
+ (p) => p.symbol === cfg.symbol && Math.abs(Number(p.contracts || 0)) > 0
3720
+ );
3721
+ if (!pos) return ok({ closed: false, reason: "No open position" });
3722
+ const amount = Math.abs(Number(pos.contracts));
3723
+ const closeSide = Number(pos.contracts) > 0 ? "sell" : "buy";
3724
+ const order = await ex.createOrder(cfg.symbol, "market", closeSide, amount);
3725
+ return ok({
3726
+ closed: true,
3727
+ side: closeSide,
3728
+ amount,
3729
+ order_id: order.id
3730
+ });
3731
+ }
3732
+ }
3733
+ } catch (e) {
3734
+ return err(e);
3735
+ }
3736
+ }
3737
+ );
3738
+ }
3739
+
3452
3740
  // src/tools/index.ts
3453
3741
  function registerAllTools(server2) {
3454
3742
  registerTradeTools(server2);
@@ -3460,13 +3748,15 @@ function registerAllTools(server2) {
3460
3748
  registerGuideTools(server2);
3461
3749
  registerFreqtradeTools(server2);
3462
3750
  registerFreqtradeDevTools(server2);
3751
+ registerFtDeployTools(server2);
3752
+ registerAutoTradeTools(server2);
3463
3753
  }
3464
3754
 
3465
3755
  // src/index.ts
3466
- var __dirname = dirname2(fileURLToPath2(import.meta.url));
3756
+ var __dirname = dirname3(fileURLToPath3(import.meta.url));
3467
3757
  var pkg = JSON.parse(
3468
- readFileSync(
3469
- resolve(__dirname, "..", "package.json"),
3758
+ readFileSync2(
3759
+ resolve3(__dirname, "..", "package.json"),
3470
3760
  "utf-8"
3471
3761
  )
3472
3762
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aicoin/aicoin-mcp",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "AiCoin MCP Server - unified crypto market data & trading via AiCoin API + CCXT",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -9,6 +9,7 @@
9
9
  },
10
10
  "files": [
11
11
  "build",
12
+ "scripts",
12
13
  "python",
13
14
  "README.md",
14
15
  ".env.example"
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env node
2
+ // Freqtrade One-Click Deployment via git clone + setup.sh (official method)
3
+ // Reads exchange keys from env vars, creates config, starts as background process
4
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { execSync } from 'node:child_process';
7
+ import { randomBytes } from 'node:crypto';
8
+
9
+ const FT_DIR = resolve(process.env.HOME || '', '.freqtrade');
10
+ const SRC_DIR = resolve(FT_DIR, 'source');
11
+ const VENV_DIR = resolve(SRC_DIR, '.venv');
12
+ const USER_DATA = resolve(FT_DIR, 'user_data');
13
+ const STRAT_DIR = resolve(USER_DATA, 'strategies');
14
+ const CONFIG_PATH = resolve(USER_DATA, 'config.json');
15
+ const PID_FILE = resolve(FT_DIR, 'freqtrade.pid');
16
+ const LOG_FILE = resolve(FT_DIR, 'freqtrade.log');
17
+ const API_PORT = process.env.FREQTRADE_PORT || '8080';
18
+
19
+ const FT_BIN = resolve(VENV_DIR, 'bin', 'freqtrade');
20
+
21
+ function run(cmd, opts = {}) {
22
+ return execSync(cmd, { encoding: 'utf-8', timeout: 600000, ...opts }).trim();
23
+ }
24
+
25
+ function hasCommand(cmd) {
26
+ try { run(`which ${cmd}`); return true; } catch { return false; }
27
+ }
28
+
29
+ // Find the best Python >= 3.11 (Freqtrade requirement)
30
+ function findPython() {
31
+ for (const bin of ['python3.13', 'python3.12', 'python3.11', 'python3']) {
32
+ try {
33
+ const version = run(`${bin} --version`);
34
+ const match = version.match(/(\d+)\.(\d+)/);
35
+ if (match) {
36
+ const major = Number(match[1]), minor = Number(match[2]);
37
+ if (major === 3 && minor >= 11) return { bin, major, minor, version };
38
+ }
39
+ } catch {}
40
+ }
41
+ return null;
42
+ }
43
+
44
+ // Ensure Python 3.11+ is available (Freqtrade requirement)
45
+ function ensureModernPython() {
46
+ let py = findPython();
47
+ if (py) return py;
48
+
49
+ // No Python 3.11+ found — try to install
50
+ if (process.platform === 'darwin') {
51
+ if (!hasCommand('brew')) {
52
+ throw new Error('Homebrew not found. Install: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"');
53
+ }
54
+ console.error('Python 3.11+ not found. Installing Python 3.12 via Homebrew...');
55
+ const brewEnv = { ...process.env, HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1' };
56
+ run('brew install python@3.12', { timeout: 300000, env: brewEnv });
57
+ py = findPython();
58
+ if (py) return py;
59
+ }
60
+
61
+ throw new Error('Python 3.11+ required. Install: brew install python@3.12 (macOS) or apt install python3.12 python3.12-venv (Linux)');
62
+ }
63
+
64
+ // ─── Exchange & config ───
65
+
66
+ function detectExchange() {
67
+ const exchanges = ['BINANCE', 'OKX', 'BYBIT', 'BITGET', 'GATE', 'HTX', 'KUCOIN', 'MEXC'];
68
+ for (const ex of exchanges) {
69
+ if (process.env[`${ex}_API_KEY`] && process.env[`${ex}_SECRET`]) {
70
+ return {
71
+ name: ex.toLowerCase(),
72
+ key: process.env[`${ex}_API_KEY`],
73
+ secret: process.env[`${ex}_SECRET`],
74
+ password: process.env[`${ex}_PASSPHRASE`] || '',
75
+ };
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function generateConfig(exchangeInfo, apiPassword, params = {}) {
82
+ const config = {
83
+ trading_mode: params.trading_mode || 'futures',
84
+ margin_mode: params.margin_mode || 'isolated',
85
+ max_open_trades: params.max_open_trades || 3,
86
+ stake_currency: 'USDT',
87
+ stake_amount: params.stake_amount || 'unlimited',
88
+ tradable_balance_ratio: params.tradable_balance_ratio || 0.5,
89
+ dry_run: params.dry_run !== false,
90
+ dry_run_wallet: 1000,
91
+ cancel_open_orders_on_exit: false,
92
+ exchange: {
93
+ name: exchangeInfo.name,
94
+ key: exchangeInfo.key,
95
+ secret: exchangeInfo.secret,
96
+ ...(exchangeInfo.password ? { password: exchangeInfo.password } : {}),
97
+ ccxt_config: {},
98
+ ccxt_async_config: {},
99
+ pair_whitelist: params.pairs || ['BTC/USDT:USDT', 'ETH/USDT:USDT'],
100
+ pair_blacklist: [],
101
+ },
102
+ pairlists: [{ method: 'StaticPairList' }],
103
+ entry_pricing: { price_side: 'same', use_order_book: true, order_book_top: 1 },
104
+ exit_pricing: { price_side: 'same', use_order_book: true, order_book_top: 1 },
105
+ api_server: {
106
+ enabled: true,
107
+ listen_ip_address: '127.0.0.1',
108
+ listen_port: Number(API_PORT),
109
+ verbosity: 'error',
110
+ enable_openapi: false,
111
+ jwt_secret_key: randomBytes(16).toString('hex'),
112
+ CORS_origins: [],
113
+ username: 'freqtrader',
114
+ password: apiPassword,
115
+ },
116
+ bot_name: 'aicoin-freqtrade',
117
+ initial_state: 'running',
118
+ force_entry_enable: true,
119
+ internals: { process_throttle_secs: 5 },
120
+ };
121
+ const proxyUrl = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
122
+ if (proxyUrl) {
123
+ config.exchange.ccxt_config.proxies = { https: proxyUrl, http: proxyUrl };
124
+ config.exchange.ccxt_async_config.aiohttp_proxy = proxyUrl;
125
+ // HTTP proxies don't support WebSocket — disable WS to force REST polling
126
+ config.exchange.enable_ws = false;
127
+ }
128
+ return config;
129
+ }
130
+
131
+ function getPid() {
132
+ if (!existsSync(PID_FILE)) return null;
133
+ const pid = readFileSync(PID_FILE, 'utf-8').trim();
134
+ if (!pid) return null;
135
+ try { process.kill(Number(pid), 0); return Number(pid); } catch { return null; }
136
+ }
137
+
138
+ // ─── Actions ───
139
+
140
+ const actions = {
141
+ check: async () => {
142
+ const checks = {};
143
+ // Python — needs 3.11+
144
+ const py = findPython();
145
+ checks.python = py ? `${py.version} (${py.bin})` : false;
146
+ if (!py) {
147
+ try {
148
+ const v = run('python3 --version');
149
+ checks.python_warning = `${v} found but Freqtrade requires 3.11+. Deploy will auto-install 3.12.`;
150
+ } catch {}
151
+ }
152
+ // git
153
+ checks.git = hasCommand('git');
154
+ // Freqtrade source
155
+ checks.source_cloned = existsSync(resolve(SRC_DIR, 'setup.sh'));
156
+ // Freqtrade installed
157
+ checks.freqtrade_installed = existsSync(FT_BIN);
158
+ if (checks.freqtrade_installed) {
159
+ try { checks.freqtrade_version = run(`${FT_BIN} --version`); } catch {}
160
+ }
161
+ // Exchange keys
162
+ const ex = detectExchange();
163
+ checks.exchange = ex ? { name: ex.name, configured: true } : { configured: false };
164
+ // Running
165
+ const pid = getPid();
166
+ checks.running = !!pid;
167
+ if (pid) checks.pid = pid;
168
+
169
+ checks.ready = (!!py || process.platform === 'darwin') && checks.git && checks.exchange?.configured;
170
+ if (!checks.ready) {
171
+ checks.missing = [];
172
+ if (!py && process.platform !== 'darwin') checks.missing.push('Python 3.11+ not found');
173
+ if (!checks.git) checks.missing.push('git not found');
174
+ if (!checks.exchange?.configured) checks.missing.push('No exchange API keys in env');
175
+ }
176
+ return checks;
177
+ },
178
+
179
+ deploy: async (params = {}) => {
180
+ // 1. Ensure Python 3.11+ (auto-installs on macOS if needed)
181
+ const py = ensureModernPython();
182
+ console.error(`Using ${py.version} (${py.bin})`);
183
+
184
+ // 2. Ensure git
185
+ if (!hasCommand('git')) throw new Error('git not found. Install: apt install git (Linux) or xcode-select --install (macOS)');
186
+
187
+ // 3. Detect exchange
188
+ const exchangeInfo = detectExchange();
189
+ if (!exchangeInfo) throw new Error('No exchange API keys found in env');
190
+
191
+ // 4. Create directories
192
+ mkdirSync(STRAT_DIR, { recursive: true });
193
+
194
+ // 5. Clone + install Freqtrade via official setup.sh
195
+ if (!existsSync(FT_BIN)) {
196
+ if (!existsSync(resolve(SRC_DIR, 'setup.sh'))) {
197
+ console.error('Cloning Freqtrade repository...');
198
+ run(`git clone https://github.com/freqtrade/freqtrade.git ${SRC_DIR}`, { timeout: 120000 });
199
+ run(`cd ${SRC_DIR} && git checkout stable`, { timeout: 30000 });
200
+ }
201
+ console.error('Running Freqtrade setup.sh (this may take a few minutes)...');
202
+ run(`cd ${SRC_DIR} && ./setup.sh -i`, { timeout: 600000 });
203
+ if (!existsSync(FT_BIN)) {
204
+ throw new Error('Freqtrade installation failed. Check output above for errors.');
205
+ }
206
+ }
207
+
208
+ // 6. Generate config
209
+ const apiPassword = randomBytes(8).toString('hex');
210
+ const config = generateConfig(exchangeInfo, apiPassword, params);
211
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
212
+
213
+ // 7. Create sample strategy if none exists
214
+ const samplePath = resolve(STRAT_DIR, 'SampleStrategy.py');
215
+ if (!existsSync(samplePath)) {
216
+ writeFileSync(samplePath, SAMPLE_STRATEGY);
217
+ }
218
+
219
+ // 8. Stop existing process
220
+ const oldPid = getPid();
221
+ if (oldPid) { try { process.kill(oldPid, 'SIGTERM'); } catch {} }
222
+
223
+ // 9. Start freqtrade as background process (with proxy env vars)
224
+ const strategy = params.strategy || 'SampleStrategy';
225
+ const proxyEnv = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
226
+ const proxyPrefix = proxyEnv ? `env HTTPS_PROXY=${proxyEnv} HTTP_PROXY=${proxyEnv} ` : '';
227
+ run(`nohup ${proxyPrefix}${FT_BIN} trade --config ${CONFIG_PATH} --strategy ${strategy} --userdir ${USER_DATA} > ${LOG_FILE} 2>&1 & echo $! > ${PID_FILE}`);
228
+
229
+ // 10. Wait for startup
230
+ let ready = false;
231
+ for (let i = 0; i < 15; i++) {
232
+ await new Promise(r => setTimeout(r, 2000));
233
+ const pid = getPid();
234
+ if (pid) {
235
+ try {
236
+ const res = await fetch(`http://127.0.0.1:${API_PORT}/api/v1/ping`, {
237
+ headers: { Authorization: 'Basic ' + Buffer.from(`freqtrader:${apiPassword}`).toString('base64') },
238
+ signal: AbortSignal.timeout(3000),
239
+ });
240
+ if (res.ok) { ready = true; break; }
241
+ } catch {}
242
+ }
243
+ }
244
+
245
+ return {
246
+ success: true,
247
+ exchange: exchangeInfo.name,
248
+ strategy,
249
+ dry_run: config.dry_run,
250
+ pairs: config.exchange.pair_whitelist,
251
+ api_url: `http://127.0.0.1:${API_PORT}`,
252
+ api_password: apiPassword,
253
+ pid: getPid(),
254
+ ready,
255
+ log_file: LOG_FILE,
256
+ config_path: CONFIG_PATH,
257
+ strategies_dir: STRAT_DIR,
258
+ note: config.dry_run
259
+ ? 'Running in DRY-RUN mode (no real money). Use deploy with {"dry_run":false} for live trading.'
260
+ : 'WARNING: Running in LIVE mode with real money!',
261
+ };
262
+ },
263
+
264
+ update: async () => {
265
+ if (!existsSync(resolve(SRC_DIR, 'setup.sh'))) {
266
+ return { error: 'Freqtrade not installed. Run deploy first.' };
267
+ }
268
+ const pid = getPid();
269
+ if (pid) { try { process.kill(pid, 'SIGTERM'); } catch {} }
270
+ console.error('Updating Freqtrade...');
271
+ run(`cd ${SRC_DIR} && ./setup.sh -u`, { timeout: 600000 });
272
+ return { updated: true, note: 'Run start to restart Freqtrade.' };
273
+ },
274
+
275
+ status: async () => {
276
+ const pid = getPid();
277
+ if (!pid) return { running: false };
278
+ let lastLogs = '';
279
+ try { lastLogs = run(`tail -5 ${LOG_FILE} 2>/dev/null`); } catch {}
280
+ return { running: true, pid, log_file: LOG_FILE, last_logs: lastLogs };
281
+ },
282
+
283
+ stop: async () => {
284
+ const pid = getPid();
285
+ if (!pid) return { stopped: false, reason: 'Not running' };
286
+ try { process.kill(pid, 'SIGTERM'); } catch {}
287
+ try { writeFileSync(PID_FILE, ''); } catch {}
288
+ return { stopped: true, pid };
289
+ },
290
+
291
+ start: async (params = {}) => {
292
+ if (getPid()) return { started: false, reason: 'Already running' };
293
+ if (!existsSync(FT_BIN)) throw new Error('Freqtrade not installed. Run deploy first.');
294
+ if (!existsSync(CONFIG_PATH)) throw new Error('No config found. Run deploy first.');
295
+ const strategy = params.strategy || 'SampleStrategy';
296
+ const proxyUrl = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
297
+ const proxyPrefix = proxyUrl ? `env HTTPS_PROXY=${proxyUrl} HTTP_PROXY=${proxyUrl} ` : '';
298
+ run(`nohup ${proxyPrefix}${FT_BIN} trade --config ${CONFIG_PATH} --strategy ${strategy} --userdir ${USER_DATA} > ${LOG_FILE} 2>&1 & echo $! > ${PID_FILE}`);
299
+ await new Promise(r => setTimeout(r, 3000));
300
+ return { started: true, pid: getPid() };
301
+ },
302
+
303
+ logs: async (params = {}) => {
304
+ const lines = params.lines || 50;
305
+ try { return { logs: run(`tail -${lines} ${LOG_FILE} 2>/dev/null`) }; } catch { return { logs: 'No log file found' }; }
306
+ },
307
+
308
+ backtest: async (params = {}) => {
309
+ if (!existsSync(FT_BIN)) throw new Error('Freqtrade not installed. Run deploy first.');
310
+ if (!existsSync(CONFIG_PATH)) throw new Error('No config found. Run deploy first.');
311
+ const strategy = params.strategy || 'SampleStrategy';
312
+ const timeframe = params.timeframe || '1h';
313
+ const timerange = params.timerange || '';
314
+ const timerangeArg = timerange ? ` --timerange ${timerange}` : '';
315
+
316
+ const proxyEnv = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
317
+ const proxyPrefix = proxyEnv ? `env HTTPS_PROXY=${proxyEnv} HTTP_PROXY=${proxyEnv} ` : '';
318
+
319
+ console.error('Downloading historical data...');
320
+ try {
321
+ run(
322
+ `${proxyPrefix}${FT_BIN} download-data --config ${CONFIG_PATH} --timeframe ${timeframe}${timerangeArg} --userdir ${USER_DATA}`,
323
+ { timeout: 300000 }
324
+ );
325
+ } catch (e) {
326
+ console.error(`Data download warning: ${e.message}`);
327
+ }
328
+
329
+ console.error(`Running backtest: strategy=${strategy}, timeframe=${timeframe}${timerange ? `, timerange=${timerange}` : ''}...`);
330
+ const output = run(
331
+ `${proxyPrefix}${FT_BIN} backtesting --config ${CONFIG_PATH} --strategy ${strategy} --timeframe ${timeframe}${timerangeArg} --userdir ${USER_DATA}`,
332
+ { timeout: 600000 }
333
+ );
334
+ return { strategy, timeframe, timerange: timerange || 'all available', output };
335
+ },
336
+
337
+ download_data: async (params = {}) => {
338
+ if (!existsSync(FT_BIN)) throw new Error('Freqtrade not installed. Run deploy first.');
339
+ if (!existsSync(CONFIG_PATH)) throw new Error('No config found. Run deploy first.');
340
+ const timeframe = params.timeframe || '1h';
341
+ const timerange = params.timerange || '';
342
+ const timerangeArg = timerange ? ` --timerange ${timerange}` : '';
343
+
344
+ const proxyEnv = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
345
+ const proxyPrefix = proxyEnv ? `env HTTPS_PROXY=${proxyEnv} HTTP_PROXY=${proxyEnv} ` : '';
346
+
347
+ console.error(`Downloading data: timeframe=${timeframe}${timerange ? `, timerange=${timerange}` : ''}...`);
348
+ const output = run(
349
+ `${proxyPrefix}${FT_BIN} download-data --config ${CONFIG_PATH} --timeframe ${timeframe}${timerangeArg} --userdir ${USER_DATA}`,
350
+ { timeout: 300000 }
351
+ );
352
+ return { timeframe, timerange: timerange || 'all available', output };
353
+ },
354
+
355
+ remove: async () => {
356
+ const pid = getPid();
357
+ if (pid) { try { process.kill(pid, 'SIGTERM'); } catch {} }
358
+ try { writeFileSync(PID_FILE, ''); } catch {}
359
+ return { removed: true, note: `Process stopped. Config preserved at ${FT_DIR}. To fully remove: rm -rf ${FT_DIR}` };
360
+ },
361
+ };
362
+
363
+ // ─── Sample strategy (pure pandas, no TA-Lib dependency) ───
364
+
365
+ const SAMPLE_STRATEGY = `# Sample RSI + EMA strategy for Freqtrade
366
+ # Uses pure pandas — no TA-Lib C library required
367
+ from freqtrade.strategy import IStrategy
368
+ from pandas import DataFrame
369
+
370
+
371
+ class SampleStrategy(IStrategy):
372
+ INTERFACE_VERSION = 3
373
+ timeframe = '5m'
374
+ can_short = True
375
+
376
+ minimal_roi = {"0": 0.05, "30": 0.03, "60": 0.02, "120": 0.01}
377
+
378
+ stoploss = -0.03
379
+ trailing_stop = True
380
+ trailing_stop_positive = 0.01
381
+ trailing_stop_positive_offset = 0.02
382
+
383
+ def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
384
+ # RSI (pure pandas, no talib)
385
+ delta = dataframe['close'].diff()
386
+ gain = delta.clip(lower=0).rolling(window=14).mean()
387
+ loss = (-delta.clip(upper=0)).rolling(window=14).mean()
388
+ rs = gain / loss
389
+ dataframe['rsi'] = 100 - (100 / (1 + rs))
390
+
391
+ # EMA (pure pandas)
392
+ dataframe['ema_fast'] = dataframe['close'].ewm(span=8, adjust=False).mean()
393
+ dataframe['ema_slow'] = dataframe['close'].ewm(span=21, adjust=False).mean()
394
+ return dataframe
395
+
396
+ def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
397
+ dataframe.loc[
398
+ (dataframe['rsi'] < 35) &
399
+ (dataframe['ema_fast'] > dataframe['ema_slow']) &
400
+ (dataframe['volume'] > 0),
401
+ 'enter_long'] = 1
402
+ dataframe.loc[
403
+ (dataframe['rsi'] > 65) &
404
+ (dataframe['ema_fast'] < dataframe['ema_slow']) &
405
+ (dataframe['volume'] > 0),
406
+ 'enter_short'] = 1
407
+ return dataframe
408
+
409
+ def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
410
+ dataframe.loc[
411
+ (dataframe['rsi'] > 70),
412
+ 'exit_long'] = 1
413
+ dataframe.loc[
414
+ (dataframe['rsi'] < 30),
415
+ 'exit_short'] = 1
416
+ return dataframe
417
+ `;
418
+
419
+ // ─── CLI ───
420
+
421
+ const [action, ...rest] = process.argv.slice(2);
422
+ if (!action || !actions[action]) {
423
+ console.log(`Usage: node ft-deploy.mjs <action> [json-params]\nActions: ${Object.keys(actions).join(', ')}`);
424
+ process.exit(1);
425
+ }
426
+ const params = rest.length ? JSON.parse(rest.join(' ')) : {};
427
+ actions[action](params).then(r => {
428
+ r._reminder = 'IMPORTANT: Always use ft_deploy tool for ALL Freqtrade operations. NEVER use Docker commands.';
429
+ console.log(JSON.stringify(r, null, 2));
430
+ }).catch(e => {
431
+ console.error(e.message);
432
+ process.exit(1);
433
+ });