
Aicoin Freqtrade
- 581 installs
- 51 repo stars
- Updated June 9, 2026
- aicoincom/coinos-skills
aicoin-freqtrade is a Python AiCoin Data SDK skill that feeds whale, funding, long-short, and liquidation signals from 200+ exchanges into Freqtrade strategies for developers building data-driven crypto bots.
About
aicoin-freqtrade is an aicoincom/coinos-skills package that imports AiCoin Open API v3 aggregated market data into Freqtrade Python strategies. The AiCoinData class auto-loads API keys from .env and exposes helpers such as whale_signal, ls_ratio_norm, funding_rate_pct, and liq_bias returning normalized numeric signals ready for strategy logic across 200+ exchanges. Developers use aicoin-freqtrade when Freqtrade bots on venues like Binance need cross-exchange whale, funding, long-short, and liquidation bias inputs without building collectors manually. It targets strategy integration rather than standalone market dashboards.
- One-line helpers return normalized numbers ready for strategy logic: whale_signal (-1..+1), ls_ratio_norm (0..1), fundin
- Auto-loads API key from .env with built-in 5-minute cache to prevent rate limits in live trading
- Supports raw endpoint access via ac.get() with full v3 catalog coverage
- Graceful fallback handling for backtest mode where real-time AiCoin data is unavailable
- Works with paid endpoints for users holding an AiCoin Open Data subscription
Aicoin Freqtrade by the numbers
- 581 all-time installs (skills.sh)
- Ranked #1,613 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/aicoincom/coinos-skills --skill aicoin-freqtradeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 581 |
|---|---|
| repo stars | ★ 51 |
| Security audit | 1 / 3 scanners passed |
| Last updated | June 9, 2026 |
| Repository | aicoincom/coinos-skills ↗ |
How do you add whale signals to Freqtrade strategies?
Pull aggregated whale, long-short, funding, and liquidation signals from 200+ exchanges directly into Freqtrade crypto trading strategies.
Who is it for?
Developers writing Freqtrade Python strategies who need AiCoin aggregated signals from 200+ exchanges as numeric inputs.
Skip if: Developers building non-Freqtrade bots or who only need REST price quotes without strategy-level signal helpers.
When should I use this skill?
A Freqtrade crypto strategy needs AiCoin whale, funding, long-short ratio, or liquidation bias data from aggregated exchange feeds.
What you get
Freqtrade strategy modules with AiCoinData imports and normalized whale, funding, long-short, and liquidation signal values.
- Freqtrade strategy with AiCoinData imports
- normalized trading signal values
By the numbers
- Aggregates market data from 200+ exchanges per skill documentation
Files
AiCoin Freqtrade
Freqtrade 策略 / 回测 / 部署 / 实时控制 — 跨 CoinClaw 三引擎自动适配。
关键原则(读完再动手)
一、CoinClaw 容器里 freqtrade 是常驻 daemon
OpenClaw / Hermes / Claude Code 三个引擎容器都通过 supervisord 把 freqtrade 起为常驻进程, 监听 127.0.0.1:8080, 默认跑 NoOpStrategy(空跑). 不要自己起 freqtrade 进程 — 会跟 daemon 抢端口, dashboard 立刻 offline.
正确流程是: 写策略文件 → 调 ft-deploy.mjs deploy {"strategy":"..."} → 脚本改 config + 重启 daemon. dashboard 会自动刷出新策略.
scripts/ft.mjs + scripts/ft-deploy.mjs 内置三引擎自动识别(lib/coinclaw-env.mjs), 路径 / auth / supervisord socket 都自动解析, agent 不用关心是哪个引擎.
二、永远先调 freqtrade REST API, 不要"自己计算"
| 用户问 | 必须先调 |
|---|---|
| 现在赚多少 / 总盈亏 / 今天涨了多少 | ft.mjs profit (/api/v1/profit) |
| 持仓 / 现在开了哪些 | ft.mjs trades_open (/api/v1/status) |
| 余额 / 资金多少 | ft.mjs balance (/api/v1/balance) |
| 跑的什么策略 / 当前模式 | ft.mjs daemon_info 或 config |
| 历史交易 / 已平仓 | ft.mjs trades_history |
| 单交易对绩效 | ft.mjs profit_per_pair |
dashboard 数字对齐规则(关键): 用户问"赚了多少"必须报告两个数字:
- 已平仓累计盈亏 =
profit_closed_coin(USDT) — dashboard 顶栏的累计盈亏 = 这个 - 含浮动总盈亏 =
profit_all_coin(USDT) — 已平仓 + 当前持仓的浮动盈亏
只调 /status 拿持仓浮动盈亏会漏掉已平仓部分, 导致跟 dashboard 数字不一致 — 用户立刻发现, 信任度归零.
三、切策略 / 切实盘 / 切交易对必须走脚本
config.json 是 daemon 启动时读一次, 手动改完不会自动生效. 必须用:
| 操作 | 命令 | 是否需要 daemon 重启 |
|---|---|---|
| 切策略 | ft.mjs set_strategy {"strategy":"X"} | 必须重启 (~30s) |
| 切交易对 | ft.mjs set_pairs {"pairs":[...]} | 不重启, reload_config 即可 |
| 切实盘/模拟 | ft.mjs set_dry_run {"dry_run":false} | 必须重启 |
| reload 配置 | ft.mjs reload | 不重启 |
或者一次完成所有变更: ft-deploy.mjs deploy {"strategy":"X","pairs":["BTC/USDT:USDT"],"dry_run":false}.
任何直接修改 config.json 的操作(包括手动编辑 pair_whitelist / minimal_roi / stoploss 等), 改完后必须立即调 `ft.mjs reload` — 否则 daemon 仍用内存里的旧配置运行, 白名单/止损等改动不会生效. 忘了 reload 是最常见的"改了但没用"的原因.
chat 主动发起的高 stake 操作必须强 confirm(违反即错):
适用: 用户在 chat 里说"平掉"、"切实盘"、"卖了"、"开仓"等 — 通过 agent 调用 force_exit / force_enter / set_dry_run 的操作.
流程: 1. 先列预览: 动哪个 trade / pair / 当前盈亏 / 估算损益 / dry_run vs live / 余额状况 2. 明确等用户输"确认"或"yes" 才真调 force_exit / set_dry_run / force_enter 3. 即使用户语气笃定("平了","直接切"), 也必须先预览等确认
不需要 confirm 的:
- 查询类(查持仓 / 盈亏 / 状态) — 直接读
- freqtrade daemon 自己根据策略信号自动开/平仓 — 这是 daemon 本职工作, 用户切实盘那一刻就授权了, agent 不在这个链路里, 不要拦也不需要 confirm
- 非破坏性配置(
set_pairs加币对、reload) — 列改动表然后直接执行
违反规则的反例:
- ❌ 用户说"平掉", 你直接调
force_exit平了真持仓 (K-Live-3 dogfood 抓到的真 bug) - ❌ 用户说"切实盘", 你不列 .env key / 余额 / 风险就直接
set_dry_run {"dry_run":false} - ✅ 用户说"平掉", 你列"持仓: BNB/USDT 0.05 +$1.07, 平这单吗? dry_run=true 模拟盘", 等用户确认
写策略 + 切策略 倾向分两轮(create_strategy 一轮, set_strategy 一轮). 不是技术限制,是 UX 选择: 1. 第一轮: 写完策略文件 → 告诉用户"已生成 X.py, 要切上去吗?" 2. 用户确认后第二轮: set_strategy 切策略 + 重启 daemon (~30s)
这样用户切策略前能 review 生成文件; daemon 重启 30s 期间用户对状态有预期, 不会误判 chat 卡死. 用户明确说"一气呵成做完"也可以单 turn 跑完两步, 但默认分轮.
四、Freqtrade 不支持网格策略 (grid)
用户问网格时直接说明限制 + 建议趋势跟踪 / 区间策略 / 网格回报模拟器替代. 不要硬写一个伪网格.
快速参考
| 任务 | 命令 |
|---|---|
| 看 daemon 状态 + 配置 | node scripts/ft-deploy.mjs check 或 ft.mjs daemon_info |
| 看策略列表 | node scripts/ft-deploy.mjs strategy_list |
| 创建策略(快速生成器) | node scripts/ft-deploy.mjs create_strategy '{"name":"MyStrat","timeframe":"15m","indicators":["rsi","macd","ema"],"aicoin_data":["funding_rate"]}' |
| 部署策略到 daemon | node scripts/ft-deploy.mjs deploy '{"strategy":"MyStrat"}' |
| 部署+切实盘 | node scripts/ft-deploy.mjs deploy '{"strategy":"MyStrat","dry_run":false}' |
| 回测 | node scripts/ft-deploy.mjs backtest '{"strategy":"MyStrat","timeframe":"1h","timerange":"20250101-20260301"}' |
| Hyperopt 调参 | node scripts/ft-deploy.mjs hyperopt '{"strategy":"MyStrat","timeframe":"1h","epochs":100}' |
| 看盈亏 | node scripts/ft.mjs profit |
| 看持仓 | node scripts/ft.mjs trades_open |
| 看余额 | node scripts/ft.mjs balance |
| 切交易对 | node scripts/ft.mjs set_pairs '{"pairs":["BTC/USDT:USDT","ETH/USDT:USDT"]}' |
| 重启 daemon | node scripts/ft.mjs restart |
| 看日志 | node scripts/ft-deploy.mjs logs '{"lines":100}' |
创建策略:先判断走哪条路
判断规则:
- 用户只给了笼统描述("RSI 策略"、"均线交叉"、"布林带回归")且没指定具体参数细节 → A. 快速生成器
- 用户给了具体逻辑(自定义入场/出场条件、跨周期共振、多币种轮动、复合指标、自定义仓位管理)→ B. 直接写 Python
- 用 A 生成后用户要改细节 → 直接编辑生成的 .py 文件,不要重新 create_strategy 覆盖
A. 快速生成器(简单策略)
create_strategy 一条命令生成一个可跑的策略文件。适合"先跑起来再调"的场景:
node scripts/ft-deploy.mjs create_strategy '{"name":"MACDStrategy","timeframe":"15m","indicators":["macd","rsi","atr"]}'
node scripts/ft-deploy.mjs create_strategy '{"name":"RSILong","timeframe":"1h","indicators":["rsi"],"direction":"long"}'
node scripts/ft-deploy.mjs create_strategy '{"name":"WhaleStrat","timeframe":"15m","indicators":["rsi","macd"],"aicoin_data":["funding_rate","ls_ratio"]}'可选 indicators: rsi, bb, ema, sma, macd, stochastic/kdj, atr, adx, cci, williams_r, vwap, ichimoku, volume_sma, obv.
可选 direction: "long" (默认,只做多) | "short" (只做空) | "both" (双向)。 用户说"RSI<30 买入, RSI>70 卖出"→ direction="long"(RSI>70 = 平多, 不是开空)。只有用户明确说"做空 / 双向 / 多空都做"时才用 "both" 或 "short"。
可选 aicoin_data: funding_rate、ls_ratio、big_orders、liquidation_map(都需付费套餐),open_interest(v3 聚合 OI 历史暂未接通,会自动降级到默认值)。
生成器的局限:只能组合预设指标,不支持跨周期、多币种轮动、自定义复合指标。遇到这些需求直接走 B。
B. 自定义 Python 策略 (复杂逻辑)
直接写 .py 文件到 daemon 的 strategy 目录。用这条路可以实现任何 freqtrade 支持的策略逻辑(跨周期 informative pairs、自定义仓位管理、多指标复合条件等)。
三引擎该目录不同, 从 `daemon_info` 拿或用 Write 工具写到下面任一路径(脚本会自动用 /api/v1/show_config 验证):
| 引擎 | strategy 目录 |
|---|---|
| OpenClaw | ~/.openclaw/workspace/strategies/ |
| Hermes | /workspace/strategies/ |
| Claude Code | /workspace/strategies/ |
用 AiCoin Python SDK (aicoin_data.py, image build 时已复制到上面目录, 也由 create_strategy 兜底拷贝):
它封装了 AiCoin Open API v3:
from aicoin_data import AiCoinData
ac = AiCoinData(cache_ttl=300) # 自动从 .env 读 key,内置 5 分钟缓存
# 高层信号 —— 直接返回能用的数字,丢进策略即可
ac.whale_signal("BTC/USDT:USDT", "binance") # 大单买卖压力 -1..+1
ac.ls_ratio_norm() # 多空比 0..1(>0.5 偏多)
ac.funding_rate_pct("BTC/USDT:USDT", "binance") # 最新资金费率(百分比)
ac.liq_bias("BTC/USDT:USDT", "binance") # 清算图方向偏向 -1..+1
# 原始数据
ac.coin_ticker("bitcoin,ethereum") # 实时行情
ac.klines("BTC/USDT", "binance", interval="1h", limit=100)
# 任意 v3 接口 —— path 是 /api/v3/ 后那段,清单见 https://open.aicoin.com/api/v3/_catalog
ac.get("markets/hot-coins", {"tab_key": "defi"})
ac.get("hyperliquid/whales/open-positions", {"coin": "BTC"})回测期 AiCoin 实时数据不可用,高层信号会抛异常 —— 策略里要 try/except 兜底用默认值。资金费率、大单、清算等需要付费套餐,没权限同样抛异常(一样兜底)。
完整模板
from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter
from pandas import DataFrame
import logging, time
logger = logging.getLogger(__name__)
class MyStrategy(IStrategy):
INTERFACE_VERSION = 3
timeframe = '15m'
can_short = True
minimal_roi = {"0": 0.05, "60": 0.03, "120": 0.01}
stoploss = -0.05
trailing_stop = True
trailing_stop_positive = 0.02
trailing_stop_positive_offset = 0.03
rsi_buy = IntParameter(20, 40, default=30, space='buy')
rsi_sell = IntParameter(60, 80, default=70, space='sell')
_ac_funding_rate = 0.0
_ac_last_update = 0.0
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# RSI
delta = dataframe['close'].diff()
gain = delta.clip(lower=0).rolling(window=14).mean()
loss = (-delta.clip(upper=0)).rolling(window=14).mean()
rs = gain / loss
dataframe['rsi'] = 100 - (100 / (1 + rs))
# AiCoin 数据 (live/dry_run only, backtest 用默认值 0.0)
dataframe['funding_rate'] = 0.0
if self.dp and self.dp.runmode.value in ('live', 'dry_run'):
now = time.time()
if now - self._ac_last_update > 300:
self._update_aicoin_data(metadata)
self._ac_last_update = now
dataframe.iloc[-1, dataframe.columns.get_loc('funding_rate')] = self._ac_funding_rate
return dataframe
def _update_aicoin_data(self, metadata: dict):
try:
import sys, os
_sd = os.path.dirname(os.path.abspath(__file__))
if _sd not in sys.path:
sys.path.insert(0, _sd)
from aicoin_data import AiCoinData
ac = AiCoinData(cache_ttl=300)
pair = metadata.get('pair', 'BTC/USDT:USDT')
exchange = self.config.get('exchange', {}).get('name', 'binance')
self._ac_funding_rate = ac.funding_rate_pct(pair, exchange)
except Exception as e:
logger.warning(f"AiCoin data error: {e}")
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe.loc[
(dataframe['rsi'] < self.rsi_buy.value) &
(dataframe['volume'] > 0),
'enter_long'] = 1
dataframe.loc[
(dataframe['rsi'] > self.rsi_sell.value) &
(dataframe['volume'] > 0),
'enter_short'] = 1
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe.loc[(dataframe['rsi'] > 70), 'exit_long'] = 1
dataframe.loc[(dataframe['rsi'] < 30), 'exit_short'] = 1
return dataframe写完后用 deploy {"strategy":"MyStrategy"} 让 daemon 切到这个策略.
AiCoin 数据集成模式
| AiCoin 数据 | 信号逻辑 | 套餐 |
|---|---|---|
funding_rate | 大于 0.01% → 多头过度 → 空信号; 小于 -0.01% → 多信号 | 基础版 |
ls_ratio | 小于 0.45 (空头多) → 反向做多; 大于 0.55 → 反向做空 | 基础版 |
big_orders | (buy_vol-sell_vol)/total > 0.3 → 鲸鱼买入做多 | 标准版 |
open_interest | OI 涨 + 价涨 = 健康趋势; OI 涨 + 价跌 = 反转 | 专业版 |
liquidation_map | 上方爆仓多 → 多头挤压 → 做多 | 高级版 |
回测注意事项
AiCoin 实时数据不在历史区间内可用. 回测时:
- AiCoin 列用默认值 (
funding_rate=0.0,ls_ratio=0.5,whale_signal=0.0) - 回测结果只反映技术指标部分
- live/dry_run 跑的时候才用真实 AiCoin 数据, 表现应该比回测好
向用户报告回测结果时必须主动说明这点, 不要让用户以为回测包含了 AiCoin 信号.
脚本 API
scripts/ft-deploy.mjs — 策略 / 回测 / 部署
| Action | 参数示例 |
|---|---|
check | (无) — 返回 daemon 状态 + 配置 + 余额 |
daemon_info(在 ft.mjs) | (无) — 单调用拿全 |
deploy | {"strategy":"MyStrat"} 或 {"strategy":"MyStrat","dry_run":false,"pairs":["BTC/USDT:USDT"]} |
create_strategy | {"name":"MyStrat","timeframe":"15m","indicators":["rsi","macd"],"direction":"long","aicoin_data":["funding_rate"]} |
backtest | {"strategy":"MyStrat","timeframe":"1h","timerange":"20250101-20260301","pairs":["ETH/USDT:USDT"]} |
hyperopt | {"strategy":"MyStrat","timeframe":"1h","epochs":100} |
download_data | {"timeframe":"1h","timerange":"20250101-"} |
strategy_list | (无) |
backtest_results | (无) — 列最近 10 个回测结果文件名 |
start / stop | (无) — coinclaw 模式调 supervisorctl, host 模式管 PID |
status / logs | {"lines":100} |
update / remove | coinclaw 模式 no-op (提示用 helm upgrade / web UI 删 instance) |
scripts/ft.mjs — 实时控制 (REST + 配置变更)
| Action | 用途 |
|---|---|
daemon_info | 一次拿 strategy / mode / pairs / open trades 数量 |
profit | 已平仓累计 + 含浮动总盈亏 (回答盈亏类问题必须先调) |
trades_open | 当前持仓 (调 /status) |
trades_history | 已平仓交易 |
balance | 余额 |
profit_per_pair | 每交易对绩效 |
daily / weekly / monthly | 时间维度统计 |
force_enter / force_exit | 手动开/平仓 |
set_strategy | 切策略 (改 config + 重启 daemon) |
set_pairs | 改交易对白名单 (reload, 不重启) |
set_dry_run | 切实盘/模拟 (改 config + 重启 daemon) |
restart | 重启 freqtrade daemon (supervisorctl + kill 兜底) |
reload | reload_config 而不重启 |
start / stop / ping / version / health | 标准 REST |
logs | freqtrade 自带 /logs 接口 |
scripts/ft-dev.mjs — 调试 (回测 / 蜡烛 / 策略详情)
backtest_start / backtest_status / backtest_history / candles_live / candles_analyzed / strategy_list / strategy_get / whitelist / blacklist 等.
环境变量与认证
.env 自动加载顺序:
- coinclaw 容器内:
/workspace/.env(Hermes/CC) 或/home/node/.openclaw/workspace/.env(OpenClaw) - host 模式: `~/.coinos/.env`(coinos 文件夹, 推荐)→ 当前目录
.env→ 旧~/.openclaw/.env(向后兼容)
freqtrade REST 认证: freqtrade-api.mjs 自动从容器内 .ft_api_pass 文件读密码, agent 不用配 FREQTRADE_USERNAME / FREQTRADE_PASSWORD. 用户也可以通过 .env 覆盖.
交易所 key 在 web UI 的 EnvSection 里配置, 写到 .env 后 entrypoint 会自动 patch 进 freqtrade config.json. agent 不要直接读 .env 给用户看交易所 key.
AiCoin Open API key (用于策略集成 AiCoin 数据):
AICOIN_ACCESS_KEY_ID=your-key-id
AICOIN_ACCESS_SECRET=your-secret付费功能引导
返回 304 / 403 时 不要重试, 直接告诉用户:
| 套餐 | 价格 | 用途 |
|---|---|---|
| 免费版 | $0 | 纯技术指标 |
| 基础版 | $29/mo | + funding_rate, ls_ratio |
| 标准版 | $79/mo | + big_orders, agg_trades |
| 高级版 | $299/mo | + liquidation_map |
| 专业版 | $699/mo | + open_interest, ai_analysis |
获取地址: https://www.aicoin.com/opendata
跨 skill 引用
| 用户问 | 用 |
|---|---|
| 单纯查行情 / K 线 / 新闻 / 资金费率 (不开仓) | aicoin-market |
| 直接下单 / 开仓 / 平仓 (不通过 freqtrade) | aicoin-trading |
| Hyperliquid 鲸鱼 / 持仓 / 清算 | aicoin-hyperliquid |
| 链上 DEX swap / 钱包余额 / gas | aicoin-onchain |
| 余额 / 持仓 / 注册 / API key 配置 (账户类) | aicoin-account |
常见 pitfall
- 不要 `cat /workspace/.ft_api_pass` 把内部 daemon 密码贴到 chat — 直接用
ft.mjs调 REST, 脚本内部读密码不会泄漏. - 不要在 chat 里 echo 用户的交易所 key — 这些是高敏数据, 引导用户去 EnvSection 配置.
- 不要自己心算 RSI / MACD / EMA — freqtrade 算出的值跟你心算结果会差, 用
ft-dev.mjs candles_analyzed拿 daemon 的真实指标. - 不要"先 stop daemon 再 freqtrade trade ... &" — 那是绕过 supervisord, 下次 dashboard 看到的还是老的 daemon 状态. 必须用
set_strategy/deploy/restart. - 回测拿不到 AiCoin 数据是正常的 — 不要造假 CSV 喂回测, 直接告诉用户回测只反映技术指标. 见根 AGENTS.md 的"数据准确性铁则".
"""AiCoin Data SDK for Freqtrade Strategies (AiCoin Open API v3)
================================================================
Import this in your Freqtrade strategy to pull AiCoin's aggregated market data
from 200+ exchanges:
from aicoin_data import AiCoinData
ac = AiCoinData() # auto-loads API key from .env
signal = ac.whale_signal('BTC/USDT:USDT', 'binance') # -1..+1
ls = ac.ls_ratio_norm() # 0..1
funding = ac.funding_rate_pct('BTC/USDT:USDT', 'binance') # percent
bias = ac.liq_bias('BTC/USDT:USDT', 'binance') # -1..+1
The high-level helpers above return plain numbers ready to drop into a strategy.
For raw responses use ac.get('<endpoint>', {...}) — see catalog at
https://open.aicoin.com/api/v3/_catalog.
Built-in 5-min cache avoids hammering the API in live mode. In backtest mode
AiCoin real-time data is not available — strategies should fall back to standard
indicators (the helpers raise, strategies catch and use defaults).
Some endpoints need a paid AiCoin subscription — see https://www.aicoin.com/opendata.
"""
import hmac
import hashlib
import base64
import json
import os
import time
import random
import logging
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import HTTPError
from urllib.parse import urlencode
logger = logging.getLogger(__name__)
# Freqtrade/CCXT exchange name -> AiCoin market slug (v3 normalizes okx internally).
EXCHANGE_MAP = {
'binance': 'binance', 'okx': 'okx', 'bybit': 'bybit', 'bitget': 'bitget',
'gate': 'gate', 'gateio': 'gate', 'htx': 'huobipro', 'huobi': 'huobipro',
'kucoin': 'kucoin',
}
# Common base ticker -> AiCoin coin_key slug. Anything else is resolved live
# via /coins/search and cached.
COIN_KEY_MAP = {
'btc': 'bitcoin', 'eth': 'ethereum', 'sol': 'solana', 'xrp': 'ripple',
'doge': 'dogecoin', 'bnb': 'binancecoin', 'ada': 'cardano', 'ltc': 'litecoin',
'link': 'chainlink', 'dot': 'polkadot', 'trx': 'tron', 'avax': 'avalanche',
'sui': 'sui', 'apt': 'aptos', 'ton': 'toncoin', 'near': 'near',
'op': 'optimism', 'arb': 'arbitrum', 'uni': 'uniswap', 'aave': 'aave',
'pepe': 'pepe', 'hype': 'hyperliquid', 'wld': 'worldcoin',
}
class AiCoinError(Exception):
"""Raised when the AiCoin API returns an error or is unreachable."""
def ccxt_to_v3(pair: str, exchange: str = 'binance') -> dict:
"""CCXT pair + exchange -> partial v3 identity {market, contract_type, base}.
'BTC/USDT:USDT' -> {'market': 'binance', 'contract_type': 'perpetual', 'base': 'btc'}
'BTC/USDT' -> {'market': 'binance', 'contract_type': 'spot', 'base': 'btc'}
The coin_key still needs resolving — use AiCoinData._pair() which does both.
"""
return {
'market': EXCHANGE_MAP.get(exchange.lower(), exchange.lower()),
'contract_type': 'perpetual' if ':' in pair else 'spot',
'base': pair.split('/')[0].lower(),
}
class AiCoinData:
"""AiCoin Open API v3 client for use inside Freqtrade strategies.
- HMAC-SHA1 signed requests, 4 X-Aic-* headers (v3 auth).
- Auto-loads the API key from .env files.
- Built-in TTL cache (default 5 min) to avoid hammering the API.
"""
_cache: dict = {} # shared across instances
def __init__(self, cache_ttl: int = 300):
self.cache_ttl = cache_ttl
self._load_env()
self._setup_proxy()
defaults = self._load_defaults()
self.base = os.environ.get('AICOIN_BASE_URL', 'https://open.aicoin.com')
self.key = os.environ.get('AICOIN_ACCESS_KEY_ID', defaults.get('accessKeyId', ''))
self.secret = os.environ.get('AICOIN_ACCESS_SECRET', defaults.get('accessSecret', ''))
# ── Setup helpers ──
@staticmethod
def _load_env():
for f in (Path('/workspace/.env'), # 容器(Hermes/CC entrypoint 注入)
Path.home() / '.coinos' / '.env', # 规范位置(coinos 文件夹), 与 JS loader 对齐
Path.cwd() / '.env',
Path.home() / '.openclaw' / 'workspace' / '.env',
Path.home() / '.openclaw' / '.env'):
if not f.exists():
continue
try:
for line in f.read_text().splitlines():
line = line.strip()
if not line or line.startswith('#'):
continue
eq = line.find('=')
if eq < 1:
continue
k, v = line[:eq].strip(), line[eq + 1:].strip()
if len(v) >= 2 and v[0] in ('"', "'") and v[-1] == v[0]:
v = v[1:-1]
os.environ.setdefault(k, v)
except Exception:
pass
@staticmethod
def _setup_proxy():
proxy = (os.environ.get('PROXY_URL') or os.environ.get('HTTPS_PROXY')
or os.environ.get('https_proxy') or os.environ.get('HTTP_PROXY')
or os.environ.get('http_proxy'))
if proxy and not proxy.startswith('socks'):
os.environ.setdefault('HTTPS_PROXY', proxy)
os.environ.setdefault('HTTP_PROXY', proxy)
@staticmethod
def _load_defaults() -> dict:
p = Path(__file__).parent / 'defaults.json'
try:
return json.loads(p.read_text()) if p.exists() else {}
except Exception:
return {}
# ── Auth + HTTP ──
def _auth_headers(self) -> dict:
nonce = '%016x' % random.getrandbits(64)
ts = str(int(time.time()))
s = f'AccessKeyId={self.key}&SignatureNonce={nonce}&Timestamp={ts}'
h = hmac.new(self.secret.encode(), s.encode(), hashlib.sha1).hexdigest()
return {
'X-Aic-AccessKey-Id': self.key,
'X-Aic-Signature-Nonce': nonce,
'X-Aic-Timestamp': ts,
'X-Aic-Signature': base64.b64encode(h.encode()).decode(),
'User-Agent': 'AiCoin-Freqtrade/2.0',
}
def _call(self, method: str, path: str, params: dict = None):
"""Sign and send a v3 request. Returns the envelope's `data`, or raises."""
full = '/api/v3/' + path.strip('/').replace('api/v3/', '', 1)
headers = self._auth_headers()
if method == 'GET':
clean = {k: (','.join(map(str, v)) if isinstance(v, (list, tuple)) else v)
for k, v in (params or {}).items() if v not in (None, '')}
qs = urlencode(clean)
req = Request(self.base + full + (('?' + qs) if qs else ''), headers=headers)
else:
headers['Content-Type'] = 'application/json'
req = Request(self.base + full, data=json.dumps(params or {}).encode(),
headers=headers, method='POST')
try:
with urlopen(req, timeout=30) as resp:
body = json.loads(resp.read())
except HTTPError as e:
try:
body = json.loads(e.read())
except Exception:
body = {}
err = body.get('error') if isinstance(body.get('error'), dict) else {}
raise AiCoinError(f"HTTP {e.code}: {err.get('message') or body.get('error') or e.reason}")
except Exception as e:
raise AiCoinError(str(e))
if body.get('ok') is False:
err = body.get('error') or {}
raise AiCoinError(err.get('message') or err.get('code') or 'request failed')
return body.get('data')
def get(self, path: str, params: dict = None, cache_key: str = None):
"""GET any v3 endpoint. `path` is the bit after /api/v3/ (e.g. 'market/klines')."""
if cache_key and self.cache_ttl > 0 and cache_key in self._cache:
ts, data = self._cache[cache_key]
if time.time() - ts < self.cache_ttl:
return data
data = self._call('GET', path, params)
if cache_key and self.cache_ttl > 0:
self._cache[cache_key] = (time.time(), data)
return data
def post(self, path: str, body: dict = None):
"""POST any v3 endpoint."""
return self._call('POST', path, body)
# ── Pair identity ──
def _resolve_coin_key(self, ticker: str) -> str:
t = ticker.lower()
if t in COIN_KEY_MAP:
return COIN_KEY_MAP[t]
ck = self._cache.get(f'ck:{t}')
if ck:
return ck[1]
try:
data = self.get('coins/search', {'query': ticker, 'limit': 5})
items = (data or {}).get('list') or (data or {}).get('items') or []
for it in items:
cand = it.get('coin_key') or it.get('coinKey') or it.get('key')
if cand:
self._cache[f'ck:{t}'] = (time.time(), cand)
return cand
except Exception:
pass
return t # last resort
def _pair(self, pair: str, exchange: str = 'binance') -> dict:
"""CCXT pair + exchange -> v3 query dict {coin_key, market, contract_type}."""
v = ccxt_to_v3(pair, exchange)
return {'coin_key': self._resolve_coin_key(v['base']),
'market': v['market'], 'contract_type': v['contract_type']}
# ── Raw data (return the v3 `data` payload) ──
def coin_ticker(self, coin_keys: str):
"""Real-time prices. coin_keys: 'bitcoin' or 'bitcoin,ethereum'."""
return self.get('coins/tickers', {'coin_key': coin_keys}, f'ticker:{coin_keys}')
def klines(self, pair: str, exchange: str = 'binance', interval: str = '1h', limit: int = 100):
"""K-line data for a CCXT pair."""
q = {**self._pair(pair, exchange), 'interval': interval, 'limit': limit}
return self.get('market/klines', q, f'kline:{pair}:{interval}:{limit}')
def funding_rate(self, pair: str, exchange: str = 'binance', limit: int = 20):
"""Funding-rate history (newest first). data.funding_rates[].close is the rate."""
q = {**self._pair(pair, exchange), 'contract_type': 'perpetual', 'limit': limit}
return self.get('derivatives/funding-rates', q, f'funding:{pair}:{limit}')
def long_short_ratio(self):
"""Cross-exchange aggregated long/short ratio summary."""
return self.get('derivatives/long-short-ratio/summary', cache_key='ls_ratio')
def big_orders(self, pair: str, exchange: str = 'binance'):
"""Whale resting orders (order-book big bids/asks)."""
q = self._pair(pair, exchange)
return self.get('market/big-orders', q, f'big_orders:{pair}')
def liquidation_map(self, pair: str, exchange: str = 'binance', window: str = '24h'):
"""Liquidation heatmap bucketed by leverage."""
q = {**self._pair(pair, exchange), 'window': window}
return self.get('derivatives/liquidations/map', q, f'liqmap:{pair}:{window}')
def hl_whale_positions(self, coin: str = None):
"""Hyperliquid whale open positions. coin is the HL symbol, e.g. 'BTC'."""
return self.get('hyperliquid/whales/open-positions',
{'coin': coin} if coin else {}, f'hl_whale:{coin}')
def hl_taker_delta(self, coin: str, interval: str = '1h'):
"""Hyperliquid accumulated taker buy/sell delta."""
return self.get('hyperliquid/accumulated-taker-delta',
{'coin': coin, 'interval': interval}, f'hl_taker:{coin}:{interval}')
# ── High-level signals (plain numbers, ready for a strategy) ──
def whale_signal(self, pair: str, exchange: str = 'binance') -> float:
"""Whale order-book pressure as -1 (selling/asks) .. +1 (buying/bids)."""
data = self.big_orders(pair, exchange)
items = (data or {}).get('items') or []
buy = sum(float(o.get('high_turnover', 0) or 0) for o in items if o.get('depth_type') == 'bid')
sell = sum(float(o.get('high_turnover', 0) or 0) for o in items if o.get('depth_type') == 'ask')
total = buy + sell
return (buy - sell) / total if total > 0 else 0.0
def ls_ratio_norm(self) -> float:
"""Long/short ratio normalized to 0..1 ( >0.5 = more longs )."""
data = self.long_short_ratio()
detail = (((data or {}).get('summary') or {}).get('detail')) or {}
ratio = float(detail.get('last', 1.0) or 1.0)
return max(0.0, min(1.0, ratio / (1.0 + ratio)))
def funding_rate_pct(self, pair: str, exchange: str = 'binance') -> float:
"""Latest funding rate as a percentage (e.g. 0.01 = 0.01%)."""
data = self.funding_rate(pair, exchange, limit=5)
rows = (data or {}).get('funding_rates') or []
if not rows:
raise AiCoinError('no funding-rate data')
# v3 时序是升序(oldest-first,与 K 线一致),rows[0] 是窗口内**最旧**的一条。
# 别假设顺序 —— 按时间字段取最大那条作为"最新";取不到时间字段才退回 rows[-1](升序末尾)。
def _ts(r):
for k in ('close_time', 'time', 'timestamp', 'ts', 'create_time', 'fundingTime'):
v = r.get(k)
if v is not None:
try:
return float(v)
except (TypeError, ValueError):
pass
return None
latest = max(rows, key=lambda r: (_ts(r) if _ts(r) is not None else float('-inf'))) \
if any(_ts(r) is not None for r in rows) else rows[-1]
return float(latest.get('close', 0) or 0) * 100
def oi_trend(self, pair: str, exchange: str = 'binance'):
"""(is_rising, change_pct) for aggregated open interest.
NOTE: v3's aggregated OI history is not wired yet (returns 501). This
raises until the data source is connected — strategies should fall back.
"""
q = {**self._pair(pair, exchange), 'interval': '15m', 'limit': 10}
data = self.get('derivatives/open-interest/stablecoin-margin', q)
rows = data if isinstance(data, list) else (data or {}).get('list') or []
if len(rows) < 2:
raise AiCoinError('no open-interest data')
first = float(rows[0].get('open_interest', rows[0].get('value', 0)) or 0)
last = float(rows[-1].get('open_interest', rows[-1].get('value', 0)) or 0)
change = (last - first) / first * 100 if first > 0 else 0.0
return (change > 3.0, change)
def liq_bias(self, pair: str, exchange: str = 'binance') -> float:
"""Liquidation-map directional bias: -1 (long liqs dominate) .. +1 (short liqs dominate)."""
data = self.liquidation_map(pair, exchange)
data_map = (((data or {}).get('map') or {}).get('data_map')) or {}
long_total = short_total = 0.0
for bucket in data_map.values():
long_total += sum(float(r[2]) for r in bucket.get('long', []) if len(r) >= 3)
short_total += sum(float(r[2]) for r in bucket.get('short', []) if len(r) >= 3)
total = long_total + short_total
return (short_total - long_total) / total if total > 0 else 0.0
# ── Cache ──
def clear_cache(self):
self._cache.clear()
def set_cache_ttl(self, seconds: int):
self.cache_ttl = seconds
// CoinClaw 三引擎(OpenClaw / Hermes / Claude Code)自动识别 helper.
//
// CoinClaw 把 freqtrade 起为 supervisord 管理的常驻 daemon, 端口 8080,
// Basic auth 用户名 'freqtrade', 密码写在容器内的 .ft_api_pass 文件.
// 三引擎的 workspace / userdir / strategy-path / config.json / .env 都
// 在不同位置, 但本 helper 屏蔽差异 — skill 脚本只关心 coinclawEnv() 返回值.
//
// 不在 CoinClaw 容器里运行(用户本地 macOS / Linux)时, coinclawEnv() 返回
// null, ft-deploy.mjs 会走 host 模式 (自己 git clone freqtrade + setup.sh).
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
// 三引擎的真实 daemon 路径(--strategy-path / --userdir / --config 等都来自
// image-*/freqtrade-launch.sh 或 image/freqtrade-wait.sh 的 exec 行).
const ENGINES = [
{
engine: 'hermes',
workspaceRoot: '/workspace',
skillsRoot: '/workspace/.hermes/skills',
freqtradeUserdir: '/workspace/freqtrade',
strategyPath: '/workspace/strategies',
configPath: '/workspace/freqtrade/config.json',
envFile: '/workspace/.env',
ftPassFile: '/workspace/.ft_api_pass',
sentinelFile: '/workspace/.hermes',
},
{
engine: 'claude-code',
workspaceRoot: '/workspace',
skillsRoot: '/workspace/.claude/skills',
freqtradeUserdir: '/workspace/freqtrade',
strategyPath: '/workspace/strategies',
configPath: '/workspace/freqtrade/config.json',
envFile: '/workspace/.env',
ftPassFile: '/workspace/.ft_api_pass',
sentinelFile: '/workspace/.claude',
},
{
engine: 'openclaw',
workspaceRoot: '/home/node/.openclaw/workspace',
skillsRoot: '/home/node/.openclaw/workspace/skills',
freqtradeUserdir: '/home/node/.openclaw/workspace/freqtrade',
strategyPath: '/home/node/.openclaw/workspace/strategies',
configPath: '/home/node/.openclaw/workspace/freqtrade/config.json',
envFile: '/home/node/.openclaw/workspace/.env',
ftPassFile: '/home/node/.openclaw/workspace/.ft_api_pass',
sentinelFile: '/home/node/.openclaw',
},
];
let _cached;
export function coinclawEnv() {
if (_cached !== undefined) return _cached;
// Hermes 和 CC 共用 /workspace, 但 sentinelFile 区分: .hermes vs .claude.
// 顺序很重要: 先匹配 .hermes (Hermes 启动时一定有 /workspace/.hermes/),
// 再匹配 .claude (CC), 最后兜底到 OpenClaw.
for (const env of ENGINES) {
if (existsSync(env.sentinelFile) && existsSync(env.configPath)) {
_cached = { ...env, ftApiUser: 'freqtrade', ftApiUrl: 'http://127.0.0.1:8080' };
return _cached;
}
}
// Hermes/CC 把 /workspace/.openclaw/workspace/.env 软链到 /workspace/.env,
// 但 sentinel 是 .hermes/.claude 而不是 .openclaw — 不会误识别成 OpenClaw.
_cached = null;
return null;
}
// 读 ft_api_pass 文件. 三引擎的 entrypoint.sh 都在第一次启动写一次,
// PVC 持久化, 之后只读不写. 文件不存在 / 读失败都返回 null, 让 caller
// 退到 .env 里的 FREQTRADE_PASSWORD (或 host 模式生成的随机密码).
export function readFtApiPass(env = coinclawEnv()) {
if (!env || !existsSync(env.ftPassFile)) return null;
try {
return readFileSync(env.ftPassFile, 'utf-8').trim() || null;
} catch {
return null;
}
}
// 用户在 chat 里填的交易所 / AiCoin / DRY_RUN 等都写到 .env,
// 三引擎共用. Host 模式下回退到 cwd / ~/.openclaw/workspace/.env.
export function envFileCandidates() {
const env = coinclawEnv();
if (env) return [env.envFile];
return [
resolve(process.env.HOME || '', '.coinos', '.env'), // 规范位置(coinos 文件夹)
resolve(process.cwd(), '.env'),
resolve(process.env.HOME || '', '.openclaw', 'workspace', '.env'),
resolve(process.env.HOME || '', '.openclaw', '.env'),
resolve(process.env.HOME || '', '.hermes', '.env'),
];
}
// 给 host 模式用. 在 CoinClaw 容器外, ft-deploy.mjs 自己 clone freqtrade
// 到 ~/.freqtrade, 写策略到 ~/.freqtrade/user_data/strategies — 老路径,
// 不动. 只在 coinclawEnv() === null 时调用.
export function hostModeFreqtradePaths() {
const home = process.env.HOME || '';
const ftDir = resolve(home, '.freqtrade');
const userData = resolve(ftDir, 'user_data');
return {
ftDir,
sourceDir: resolve(ftDir, 'source'),
venvDir: resolve(ftDir, 'source', '.venv'),
userdir: userData,
strategyPath: resolve(userData, 'strategies'),
configPath: resolve(userData, 'config.json'),
pidFile: resolve(ftDir, 'freqtrade.pid'),
logFile: resolve(ftDir, 'freqtrade.log'),
ftBin: resolve(ftDir, 'source', '.venv', 'bin', 'freqtrade'),
};
}
// 判断是否能 supervisorctl(只有在 coinclaw 容器里有). 用于 ft.mjs 的
// restart_daemon action — supervisorctl 走 unix socket, 路径见
// image-*/supervisord.conf. 三引擎的 socket 位置略不同, 这里用 socket
// 实际路径而不是 supervisorctl 的默认 search.
export function supervisorSocket() {
const env = coinclawEnv();
if (!env) return null;
if (env.engine === 'openclaw') return '/tmp/supervisor.sock';
// Hermes/CC: file=/workspace/supervisor.sock
return '/workspace/supervisor.sock';
}
// 仅供测试: 强制清缓存. 生产代码不要调用.
export function _resetCacheForTesting() {
_cached = undefined;
}
{
"comment": "Public free-tier AiCoin API key. IP rate-limited. Users can replace with their own key via env vars.",
"accessKeyId": "ronJ8uI0Yj2soAfGVs5H1YALUIINbE22",
"accessSecret": "CWHZcH2us1CLSE7grroR1TpS0Z1JxTwU"
}
#!/usr/bin/env node
// Freqtrade REST API client — shared helper.
//
// 在 CoinClaw 三引擎(OpenClaw / Hermes / Claude Code)容器里,
// freqtrade 是 supervisord 管的常驻 daemon 跑在 :8080, Basic auth
// 用户名 'freqtrade', 密码写在容器内的 .ft_api_pass 文件 (PVC 持久化).
// 这个 helper 自动从那里读 — agent / skill 不需要在 .env 里再配
// FREQTRADE_USERNAME / FREQTRADE_PASSWORD. 用户也可以通过 .env 覆盖.
//
// 在容器外(用户本地 macOS / Linux), 退到 .env 里读 — 走老的 host 模式,
// ft-deploy.mjs deploy 时把 FREQTRADE_PASSWORD 写到 .env.
import { readFileSync, existsSync } from 'node:fs';
import { coinclawEnv, readFtApiPass, envFileCandidates } from './coinclaw-env.mjs';
// Auto-load .env files (CoinClaw 容器优先 /workspace/.env 或 OpenClaw 的等价路径).
function loadEnv() {
for (const file of envFileCandidates()) {
if (!existsSync(file)) continue;
try {
for (const line of readFileSync(file, 'utf-8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 1) continue;
const key = t.slice(0, eq).trim();
let val = t.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
if (!process.env[key]) process.env[key] = val;
}
} catch {}
}
}
loadEnv();
const env = coinclawEnv();
// 优先级有两条路径:
// - CoinClaw 容器内 (env 非空): 信 daemon 真实密码 (.ft_api_pass 文件
// + 容器 entrypoint 注入的 FT_API_USER), 完全忽略 .env 里的
// FREQTRADE_USERNAME/PASSWORD. 后者可能是早期 ft-deploy.mjs deploy
// 流程 appendEnv 写的过时值, daemon 重启后密码会变, .env 没跟新 →
// 401. 端到端测试在 OpenClaw pod 重现过这个 bug.
// - host 模式 (env=null): 信用户的 .env 配置, 因为本地 freqtrade 不是
// supervisord 管的, 没有 .ft_api_pass 文件这个权威来源.
let BASE, USER, PASS;
if (env) {
BASE = env.ftApiUrl;
USER = env.ftApiUser;
PASS = readFtApiPass(env) || '';
} else {
BASE = process.env.FREQTRADE_URL || 'http://localhost:8080';
USER = process.env.FREQTRADE_USERNAME || 'freqtrade';
PASS = process.env.FREQTRADE_PASSWORD || '';
}
const auth = 'Basic ' + Buffer.from(`${USER}:${PASS}`).toString('base64');
export async function ftGet(path, params = {}) {
const url = new URL(`/api/v1/${path}`, BASE);
for (const [k, v] of Object.entries(params)) {
if (v != null) url.searchParams.set(k, String(v));
}
const res = await fetch(url, { headers: { Authorization: auth }, signal: AbortSignal.timeout(30000) });
if (!res.ok) throw new Error(`Freqtrade ${res.status}: ${await res.text()}`);
return res.json();
}
export async function ftPost(path, body = {}) {
const res = await fetch(new URL(`/api/v1/${path}`, BASE), {
method: 'POST',
headers: { Authorization: auth, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(30000),
});
if (!res.ok) throw new Error(`Freqtrade ${res.status}: ${await res.text()}`);
return res.json();
}
export async function ftDelete(path) {
const res = await fetch(new URL(`/api/v1/${path}`, BASE), {
method: 'DELETE',
headers: { Authorization: auth },
signal: AbortSignal.timeout(30000),
});
if (!res.ok) throw new Error(`Freqtrade ${res.status}: ${await res.text()}`);
return res.json();
}
// CLI helper
export function ftCli(handlers) {
const [action, ...rest] = process.argv.slice(2);
if (!action || !handlers[action]) {
console.log(`Usage: node <script> <action> [json-params]\nActions: ${Object.keys(handlers).join(', ')}`);
process.exit(1);
}
let params = {};
if (rest.length) {
try {
params = JSON.parse(rest.join(' '));
} catch {
console.log(JSON.stringify({ error: '参数不是合法 JSON: ' + rest.join(' '), hint: "参数要用 JSON 对象, 例: '{\"strategy\":\"MyStrat\"}'" }));
process.exit(1);
}
}
handlers[action](params).then(r => console.log(JSON.stringify(r, null, 2))).catch(e => {
console.error(e.message); process.exit(1);
});
}
// Strategy code generator (从 ft-deploy.mjs 抽出来, 单一职责).
//
// 用 indicators[] + aicoin_data[] + 可选的 entry_logic / exit_logic 拼出
// 一份 freqtrade IStrategy 子类 .py 文件文本. 选 indicator 就生成对应的
// pandas 计算块, 选 aicoin_data 就生成 _update_aicoin_data + 在 entry 加
// 对应的过滤条件.
//
// 为什么不用 freqtrade-templates: 这里的目标是给 agent / 用户低门槛快速
// 生成可跑的策略, 不希望 agent 还要 mkdir / cp template / search-replace.
// 内置生成器一次产出完整文件.
export const AVAILABLE_INDICATORS = [
'rsi', 'bb', 'bollinger', 'ema', 'sma', 'macd',
'stochastic', 'kdj', 'atr', 'adx', 'cci',
'williams_r', 'willr', 'vwap', 'ichimoku',
'volume_sma', 'volume', 'obv',
];
export const AVAILABLE_AICOIN_DATA = [
'funding_rate (付费套餐)',
'ls_ratio (付费套餐)',
'big_orders (付费套餐)',
'open_interest (v3 聚合 OI 历史暂未接通,会自动降级)',
'liquidation_map (付费套餐)',
];
export const PAID_DATA = {
funding_rate: '付费套餐',
ls_ratio: '付费套餐',
big_orders: '付费套餐',
open_interest: '付费套餐(注意 v3 聚合 OI 历史暂未接通)',
liquidation_map: '付费套餐',
};
export function buildStrategyCode(name, tf, desc, ds, indicators, entryLogic, exitLogic, direction = 'long') {
const L = []; // lines
const has = (k) => ds.has(k);
const any = ds.size > 0;
const defaultIndicators = ['rsi', 'bb', 'ema', 'volume_sma'];
const allIndicators = new Set(indicators && indicators.length ? indicators.map((i) => i.toLowerCase()) : defaultIndicators);
const hasInd = (k) => allIndicators.has(k);
L.push(`# ${name} - ${desc}`);
if (any) L.push(`# AiCoin data: ${[...ds].join(', ')} (live/dry_run only)`);
L.push(`# Indicators: ${[...allIndicators].join(', ')}`);
L.push(`# Backtest: uses technical indicators only`);
L.push(`#`);
L.push(`from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter`);
L.push(`from pandas import DataFrame`);
L.push(`import logging`);
L.push(``);
L.push(`logger = logging.getLogger(__name__)`);
L.push(``);
L.push(``);
L.push(`class ${name}(IStrategy):`);
L.push(` INTERFACE_VERSION = 3`);
L.push(` timeframe = '${tf}'`);
const canShort = direction === 'both' || direction === 'short';
L.push(` can_short = ${canShort ? 'True' : 'False'}`);
L.push(``);
L.push(` minimal_roi = {"0": 0.05, "60": 0.03, "120": 0.01}`);
L.push(` stoploss = -0.05`);
L.push(` trailing_stop = True`);
L.push(` trailing_stop_positive = 0.02`);
L.push(` trailing_stop_positive_offset = 0.03`);
L.push(``);
L.push(` # Hyperopt parameters`);
if (hasInd('rsi')) {
L.push(` rsi_buy = IntParameter(20, 40, default=30, space='buy')`);
L.push(` rsi_sell = IntParameter(60, 80, default=70, space='sell')`);
}
if (hasInd('stochastic') || hasInd('kdj')) {
L.push(` stoch_buy = IntParameter(10, 30, default=20, space='buy')`);
L.push(` stoch_sell = IntParameter(70, 90, default=80, space='sell')`);
}
if (hasInd('cci')) {
L.push(` cci_buy = IntParameter(-200, -50, default=-100, space='buy')`);
L.push(` cci_sell = IntParameter(50, 200, default=100, space='sell')`);
}
if (hasInd('williams_r') || hasInd('willr')) {
L.push(` willr_buy = IntParameter(-90, -70, default=-80, space='buy')`);
L.push(` willr_sell = IntParameter(-30, -10, default=-20, space='sell')`);
}
if (has('funding_rate'))
L.push(` funding_threshold = DecimalParameter(0.005, 0.1, default=0.01, space='buy')`);
L.push(``);
if (any) {
L.push(` # AiCoin cached data (updated every 5 min in live mode)`);
if (has('funding_rate')) L.push(` _ac_funding_rate = 0.0`);
if (has('ls_ratio')) L.push(` _ac_ls_ratio = 0.5`);
if (has('big_orders')) L.push(` _ac_whale_signal = 0.0`);
if (has('open_interest')) L.push(` _ac_oi_rising = False`);
if (has('liquidation_map')) L.push(` _ac_liq_bias = 0.0`);
L.push(` _ac_last_update = 0.0`);
L.push(``);
}
L.push(` def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:`);
if (hasInd('rsi')) {
L.push(` # RSI`);
L.push(` delta = dataframe['close'].diff()`);
L.push(` gain = delta.clip(lower=0).rolling(window=14).mean()`);
L.push(` loss = (-delta.clip(upper=0)).rolling(window=14).mean()`);
L.push(` rs = gain / loss`);
L.push(` dataframe['rsi'] = 100 - (100 / (1 + rs))`);
L.push(``);
}
if (hasInd('bb') || hasInd('bollinger')) {
L.push(` # Bollinger Bands`);
L.push(` dataframe['bb_mid'] = dataframe['close'].rolling(window=20).mean()`);
L.push(` bb_std = dataframe['close'].rolling(window=20).std()`);
L.push(` dataframe['bb_upper'] = dataframe['bb_mid'] + 2 * bb_std`);
L.push(` dataframe['bb_lower'] = dataframe['bb_mid'] - 2 * bb_std`);
L.push(``);
}
if (hasInd('ema')) {
L.push(` # EMA`);
L.push(` dataframe['ema_fast'] = dataframe['close'].ewm(span=8, adjust=False).mean()`);
L.push(` dataframe['ema_slow'] = dataframe['close'].ewm(span=21, adjust=False).mean()`);
L.push(``);
}
if (hasInd('sma')) {
L.push(` # SMA`);
L.push(` dataframe['sma_short'] = dataframe['close'].rolling(window=10).mean()`);
L.push(` dataframe['sma_long'] = dataframe['close'].rolling(window=50).mean()`);
L.push(``);
}
if (hasInd('macd')) {
L.push(` # MACD`);
L.push(` ema12 = dataframe['close'].ewm(span=12, adjust=False).mean()`);
L.push(` ema26 = dataframe['close'].ewm(span=26, adjust=False).mean()`);
L.push(` dataframe['macd'] = ema12 - ema26`);
L.push(` dataframe['macd_signal'] = dataframe['macd'].ewm(span=9, adjust=False).mean()`);
L.push(` dataframe['macd_hist'] = dataframe['macd'] - dataframe['macd_signal']`);
L.push(``);
}
if (hasInd('stochastic') || hasInd('kdj')) {
L.push(` # Stochastic (KDJ)`);
L.push(` low14 = dataframe['low'].rolling(window=14).min()`);
L.push(` high14 = dataframe['high'].rolling(window=14).max()`);
L.push(` dataframe['stoch_k'] = 100 * (dataframe['close'] - low14) / (high14 - low14)`);
L.push(` dataframe['stoch_d'] = dataframe['stoch_k'].rolling(window=3).mean()`);
L.push(` dataframe['stoch_j'] = 3 * dataframe['stoch_k'] - 2 * dataframe['stoch_d']`);
L.push(``);
}
if (hasInd('atr')) {
L.push(` # ATR (Average True Range)`);
L.push(` high_low = dataframe['high'] - dataframe['low']`);
L.push(` high_close = (dataframe['high'] - dataframe['close'].shift()).abs()`);
L.push(` low_close = (dataframe['low'] - dataframe['close'].shift()).abs()`);
L.push(` tr = high_low.combine(high_close, max).combine(low_close, max)`);
L.push(` dataframe['atr'] = tr.rolling(window=14).mean()`);
L.push(``);
}
if (hasInd('adx')) {
L.push(` # ADX (Average Directional Index)`);
L.push(` plus_dm = dataframe['high'].diff().clip(lower=0)`);
L.push(` minus_dm = (-dataframe['low'].diff()).clip(lower=0)`);
L.push(` _tr = (dataframe['high'] - dataframe['low']).combine((dataframe['high'] - dataframe['close'].shift()).abs(), max).combine((dataframe['low'] - dataframe['close'].shift()).abs(), max)`);
L.push(` atr14 = _tr.rolling(window=14).mean()`);
L.push(` plus_di = 100 * plus_dm.rolling(window=14).mean() / atr14`);
L.push(` minus_di = 100 * minus_dm.rolling(window=14).mean() / atr14`);
L.push(` dx = 100 * (plus_di - minus_di).abs() / (plus_di + minus_di)`);
L.push(` dataframe['adx'] = dx.rolling(window=14).mean()`);
L.push(` dataframe['plus_di'] = plus_di`);
L.push(` dataframe['minus_di'] = minus_di`);
L.push(``);
}
if (hasInd('cci')) {
L.push(` # CCI (Commodity Channel Index)`);
L.push(` tp = (dataframe['high'] + dataframe['low'] + dataframe['close']) / 3`);
L.push(` tp_sma = tp.rolling(window=20).mean()`);
L.push(` tp_mad = tp.rolling(window=20).apply(lambda x: (x - x.mean()).abs().mean(), raw=True)`);
L.push(` dataframe['cci'] = (tp - tp_sma) / (0.015 * tp_mad)`);
L.push(``);
}
if (hasInd('williams_r') || hasInd('willr')) {
L.push(` # Williams %R`);
L.push(` high14_w = dataframe['high'].rolling(window=14).max()`);
L.push(` low14_w = dataframe['low'].rolling(window=14).min()`);
L.push(` dataframe['willr'] = -100 * (high14_w - dataframe['close']) / (high14_w - low14_w)`);
L.push(``);
}
if (hasInd('vwap')) {
L.push(` # VWAP (approximation using cumulative)`);
L.push(` tp_v = (dataframe['high'] + dataframe['low'] + dataframe['close']) / 3`);
L.push(` cum_tpv = (tp_v * dataframe['volume']).rolling(window=20).sum()`);
L.push(` cum_vol = dataframe['volume'].rolling(window=20).sum()`);
L.push(` dataframe['vwap'] = cum_tpv / cum_vol`);
L.push(``);
}
if (hasInd('ichimoku')) {
L.push(` # Ichimoku Cloud`);
L.push(` nine_high = dataframe['high'].rolling(window=9).max()`);
L.push(` nine_low = dataframe['low'].rolling(window=9).min()`);
L.push(` dataframe['tenkan'] = (nine_high + nine_low) / 2`);
L.push(` twentysix_high = dataframe['high'].rolling(window=26).max()`);
L.push(` twentysix_low = dataframe['low'].rolling(window=26).min()`);
L.push(` dataframe['kijun'] = (twentysix_high + twentysix_low) / 2`);
L.push(` dataframe['senkou_a'] = ((dataframe['tenkan'] + dataframe['kijun']) / 2).shift(26)`);
L.push(` fiftytwo_high = dataframe['high'].rolling(window=52).max()`);
L.push(` fiftytwo_low = dataframe['low'].rolling(window=52).min()`);
L.push(` dataframe['senkou_b'] = ((fiftytwo_high + fiftytwo_low) / 2).shift(26)`);
L.push(``);
}
if (hasInd('volume_sma') || hasInd('volume')) {
L.push(` # Volume SMA`);
L.push(` dataframe['vol_sma'] = dataframe['volume'].rolling(window=20).mean()`);
}
if (hasInd('obv')) {
L.push(` # OBV (On Balance Volume)`);
L.push(` import numpy as np`);
L.push(` obv_sign = np.where(dataframe['close'] > dataframe['close'].shift(), 1, np.where(dataframe['close'] < dataframe['close'].shift(), -1, 0))`);
L.push(` dataframe['obv'] = (obv_sign * dataframe['volume']).cumsum()`);
L.push(` dataframe['obv_sma'] = dataframe['obv'].rolling(window=20).mean()`);
L.push(``);
}
if (any) {
L.push(``);
L.push(` # AiCoin data columns (default values for backtest)`);
if (has('funding_rate')) {
L.push(` dataframe['funding_rate'] = 0.0`);
L.push(` dataframe['funding_extreme'] = 0`);
}
if (has('ls_ratio')) L.push(` dataframe['ls_ratio'] = 0.5`);
if (has('big_orders')) L.push(` dataframe['whale_signal'] = 0.0`);
if (has('open_interest')) L.push(` dataframe['oi_rising'] = 0`);
if (has('liquidation_map')) L.push(` dataframe['liq_bias'] = 0.0`);
L.push(``);
L.push(` if self.dp and self.dp.runmode.value in ('live', 'dry_run'):`);
L.push(` import time`);
L.push(` now = time.time()`);
L.push(` if now - self._ac_last_update > 300:`);
L.push(` self._update_aicoin_data(metadata)`);
L.push(` self._ac_last_update = now`);
L.push(``);
if (has('funding_rate')) {
L.push(` dataframe.iloc[-1, dataframe.columns.get_loc('funding_rate')] = self._ac_funding_rate`);
L.push(` t = self.funding_threshold.value`);
L.push(` if self._ac_funding_rate > t:`);
L.push(` dataframe.iloc[-1, dataframe.columns.get_loc('funding_extreme')] = 1`);
L.push(` elif self._ac_funding_rate < -t:`);
L.push(` dataframe.iloc[-1, dataframe.columns.get_loc('funding_extreme')] = -1`);
}
if (has('ls_ratio'))
L.push(` dataframe.iloc[-1, dataframe.columns.get_loc('ls_ratio')] = self._ac_ls_ratio`);
if (has('big_orders'))
L.push(` dataframe.iloc[-1, dataframe.columns.get_loc('whale_signal')] = self._ac_whale_signal`);
if (has('open_interest'))
L.push(` dataframe.iloc[-1, dataframe.columns.get_loc('oi_rising')] = 1 if self._ac_oi_rising else 0`);
if (has('liquidation_map'))
L.push(` dataframe.iloc[-1, dataframe.columns.get_loc('liq_bias')] = self._ac_liq_bias`);
}
L.push(``);
L.push(` return dataframe`);
L.push(``);
if (any) {
L.push(` def _update_aicoin_data(self, metadata: dict):`);
L.push(` try:`);
L.push(` import sys, os`);
L.push(` _sd = os.path.dirname(os.path.abspath(__file__))`);
L.push(` if _sd not in sys.path:`);
L.push(` sys.path.insert(0, _sd)`);
L.push(` from aicoin_data import AiCoinData`);
L.push(` ac = AiCoinData(cache_ttl=300)`);
L.push(` pair = metadata.get('pair', 'BTC/USDT:USDT')`);
L.push(` exchange = self.config.get('exchange', {}).get('name', 'binance')`);
L.push(``);
if (has('funding_rate')) {
L.push(` try:`);
L.push(` self._ac_funding_rate = ac.funding_rate_pct(pair, exchange)`);
L.push(` logger.info(f"AiCoin funding rate for {pair}: {self._ac_funding_rate:.4f}%")`);
L.push(` except Exception as e:`);
L.push(` logger.debug(f"AiCoin funding_rate unavailable: {e}")`);
L.push(``);
}
if (has('ls_ratio')) {
L.push(` try:`);
L.push(` self._ac_ls_ratio = ac.ls_ratio_norm()`);
L.push(` logger.info(f"AiCoin L/S ratio: {self._ac_ls_ratio:.2f}")`);
L.push(` except Exception as e:`);
L.push(` logger.debug(f"AiCoin ls_ratio unavailable: {e}")`);
L.push(``);
}
if (has('big_orders')) {
L.push(` try:`);
L.push(` self._ac_whale_signal = ac.whale_signal(pair, exchange)`);
L.push(` logger.info(f"AiCoin whale signal for {pair}: {self._ac_whale_signal:.2f}")`);
L.push(` except Exception as e:`);
L.push(` logger.debug(f"AiCoin big_orders unavailable: {e}")`);
L.push(``);
}
if (has('open_interest')) {
L.push(` try:`);
L.push(` self._ac_oi_rising, _chg = ac.oi_trend(pair, exchange)`);
L.push(` logger.info(f"AiCoin OI rising={self._ac_oi_rising} change={_chg:.2f}%")`);
L.push(` except Exception as e:`);
L.push(` logger.debug(f"AiCoin OI unavailable: {e}")`);
L.push(``);
}
if (has('liquidation_map')) {
L.push(` try:`);
L.push(` self._ac_liq_bias = ac.liq_bias(pair, exchange)`);
L.push(` logger.info(f"AiCoin liq bias for {pair}: {self._ac_liq_bias:.2f}")`);
L.push(` except Exception as e:`);
L.push(` logger.debug(f"AiCoin liquidation_map unavailable: {e}")`);
L.push(``);
}
L.push(` except ImportError:`);
L.push(` logger.warning("aicoin_data module not found. Run ft-deploy.mjs to install.")`);
L.push(` except Exception as e:`);
L.push(` logger.warning(f"AiCoin data error: {e}")`);
L.push(``);
}
// populate_entry_trend
const longC = [];
const shortC = [];
if (entryLogic && entryLogic.long) {
longC.push(`(${entryLogic.long})`);
shortC.push(`(${entryLogic.short || entryLogic.long})`);
} else {
if (hasInd('rsi')) {
longC.push("(dataframe['rsi'] < self.rsi_buy.value)");
shortC.push("(dataframe['rsi'] > self.rsi_sell.value)");
}
if (hasInd('ema')) {
longC.push("(dataframe['ema_fast'] > dataframe['ema_slow'])");
shortC.push("(dataframe['ema_fast'] < dataframe['ema_slow'])");
}
if (hasInd('sma')) {
longC.push("(dataframe['sma_short'] > dataframe['sma_long'])");
shortC.push("(dataframe['sma_short'] < dataframe['sma_long'])");
}
if (hasInd('macd')) {
longC.push("(dataframe['macd'] > dataframe['macd_signal'])");
shortC.push("(dataframe['macd'] < dataframe['macd_signal'])");
}
if (hasInd('stochastic') || hasInd('kdj')) {
longC.push("(dataframe['stoch_k'] < self.stoch_buy.value)");
shortC.push("(dataframe['stoch_k'] > self.stoch_sell.value)");
}
if (hasInd('bb') || hasInd('bollinger')) {
longC.push("(dataframe['close'] < dataframe['bb_lower'])");
shortC.push("(dataframe['close'] > dataframe['bb_upper'])");
}
if (hasInd('cci')) {
longC.push("(dataframe['cci'] < self.cci_buy.value)");
shortC.push("(dataframe['cci'] > self.cci_sell.value)");
}
if (hasInd('williams_r') || hasInd('willr')) {
longC.push("(dataframe['willr'] < self.willr_buy.value)");
shortC.push("(dataframe['willr'] > self.willr_sell.value)");
}
if (hasInd('adx')) {
longC.push("(dataframe['adx'] > 20) & (dataframe['plus_di'] > dataframe['minus_di'])");
shortC.push("(dataframe['adx'] > 20) & (dataframe['minus_di'] > dataframe['plus_di'])");
}
if (hasInd('ichimoku')) {
longC.push("(dataframe['close'] > dataframe['senkou_a']) & (dataframe['close'] > dataframe['senkou_b'])");
shortC.push("(dataframe['close'] < dataframe['senkou_a']) & (dataframe['close'] < dataframe['senkou_b'])");
}
if (hasInd('vwap')) {
longC.push("(dataframe['close'] < dataframe['vwap'])");
shortC.push("(dataframe['close'] > dataframe['vwap'])");
}
if (hasInd('obv')) {
longC.push("(dataframe['obv'] > dataframe['obv_sma'])");
shortC.push("(dataframe['obv'] < dataframe['obv_sma'])");
}
if (hasInd('volume_sma') || hasInd('volume')) {
longC.push("(dataframe['volume'] > dataframe['vol_sma'] * 0.5)");
shortC.push("(dataframe['volume'] > dataframe['vol_sma'] * 0.5)");
}
if (longC.length === 0) {
longC.push("(dataframe['volume'] > 0)");
shortC.push("(dataframe['volume'] > 0)");
}
}
if (has('funding_rate')) { longC.push("(dataframe['funding_extreme'] <= 0)"); shortC.push("(dataframe['funding_extreme'] >= 0)"); }
if (has('ls_ratio')) { longC.push("(dataframe['ls_ratio'] <= 0.55)"); shortC.push("(dataframe['ls_ratio'] >= 0.45)"); }
if (has('big_orders')) { longC.push("(dataframe['whale_signal'] >= -0.3)"); shortC.push("(dataframe['whale_signal'] <= 0.3)"); }
if (has('liquidation_map')) { longC.push("(dataframe['liq_bias'] >= -0.3)"); shortC.push("(dataframe['liq_bias'] <= 0.3)"); }
const doLong = direction === 'long' || direction === 'both';
const doShort = direction === 'short' || direction === 'both';
L.push(` def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:`);
if (doLong) {
L.push(` dataframe.loc[`);
longC.forEach((c, i) => L.push(` ${c}${i < longC.length - 1 ? ' &' : ','}`));
L.push(` 'enter_long'] = 1`);
L.push(``);
}
if (doShort) {
L.push(` dataframe.loc[`);
shortC.forEach((c, i) => L.push(` ${c}${i < shortC.length - 1 ? ' &' : ','}`));
L.push(` 'enter_short'] = 1`);
L.push(``);
}
L.push(` return dataframe`);
L.push(``);
L.push(` def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:`);
if (exitLogic && exitLogic.long) {
if (doLong) {
L.push(` dataframe.loc[`);
L.push(` (${exitLogic.long}),`);
L.push(` 'exit_long'] = 1`);
}
if (doShort) {
L.push(` dataframe.loc[`);
L.push(` (${exitLogic.short || exitLogic.long}),`);
L.push(` 'exit_short'] = 1`);
}
} else {
if (hasInd('rsi')) {
if (doLong) {
L.push(` dataframe.loc[`);
L.push(` (dataframe['rsi'] > 70),`);
L.push(` 'exit_long'] = 1`);
}
if (doShort) {
L.push(` dataframe.loc[`);
L.push(` (dataframe['rsi'] < 30),`);
L.push(` 'exit_short'] = 1`);
}
} else if (hasInd('stochastic') || hasInd('kdj')) {
if (doLong) {
L.push(` dataframe.loc[`);
L.push(` (dataframe['stoch_k'] > 80),`);
L.push(` 'exit_long'] = 1`);
}
if (doShort) {
L.push(` dataframe.loc[`);
L.push(` (dataframe['stoch_k'] < 20),`);
L.push(` 'exit_short'] = 1`);
}
} else if (hasInd('cci')) {
if (doLong) {
L.push(` dataframe.loc[`);
L.push(` (dataframe['cci'] > 150),`);
L.push(` 'exit_long'] = 1`);
}
if (doShort) {
L.push(` dataframe.loc[`);
L.push(` (dataframe['cci'] < -150),`);
L.push(` 'exit_short'] = 1`);
}
} else if (hasInd('macd')) {
if (doLong) {
L.push(` dataframe.loc[`);
L.push(` (dataframe['macd'] < dataframe['macd_signal']),`);
L.push(` 'exit_long'] = 1`);
}
if (doShort) {
L.push(` dataframe.loc[`);
L.push(` (dataframe['macd'] > dataframe['macd_signal']),`);
L.push(` 'exit_short'] = 1`);
}
} else {
L.push(` dataframe.loc[`);
L.push(` (dataframe['volume'] > 0), # exits handled by ROI/stoploss`);
L.push(` 'exit_long'] = 0 # placeholder`);
if (doShort) {
L.push(` dataframe.loc[`);
L.push(` (dataframe['volume'] > 0),`);
L.push(` 'exit_short'] = 0`);
}
}
}
L.push(` return dataframe`);
L.push(``);
return L.join('\n');
}
// 极简策略, 给 host 模式 deploy 没指定 strategy 时兜底.
export const SAMPLE_STRATEGY = `# Sample RSI + EMA strategy for Freqtrade
# Uses pure pandas — no TA-Lib C library required
from freqtrade.strategy import IStrategy
from pandas import DataFrame
class SampleStrategy(IStrategy):
INTERFACE_VERSION = 3
timeframe = '5m'
can_short = True
minimal_roi = {"0": 0.05, "30": 0.03, "60": 0.02, "120": 0.01}
stoploss = -0.03
trailing_stop = True
trailing_stop_positive = 0.01
trailing_stop_positive_offset = 0.02
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# RSI (pure pandas, no talib)
delta = dataframe['close'].diff()
gain = delta.clip(lower=0).rolling(window=14).mean()
loss = (-delta.clip(upper=0)).rolling(window=14).mean()
rs = gain / loss
dataframe['rsi'] = 100 - (100 / (1 + rs))
# EMA (pure pandas)
dataframe['ema_fast'] = dataframe['close'].ewm(span=8, adjust=False).mean()
dataframe['ema_slow'] = dataframe['close'].ewm(span=21, adjust=False).mean()
return dataframe
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe.loc[
(dataframe['rsi'] < 35) &
(dataframe['ema_fast'] > dataframe['ema_slow']) &
(dataframe['volume'] > 0),
'enter_long'] = 1
dataframe.loc[
(dataframe['rsi'] > 65) &
(dataframe['ema_fast'] < dataframe['ema_slow']) &
(dataframe['volume'] > 0),
'enter_short'] = 1
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
dataframe.loc[
(dataframe['rsi'] > 70),
'exit_long'] = 1
dataframe.loc[
(dataframe['rsi'] < 30),
'exit_short'] = 1
return dataframe
`;
{
"name": "aicoin-freqtrade",
"version": "3.5.2",
"private": true,
"type": "module"
}#!/usr/bin/env node
// ft-deploy.mjs — strategy lifecycle, backtest, hyperopt.
//
// 两套运行模式自动切换:
// - CoinClaw 容器内 (OpenClaw / Hermes / Claude Code): freqtrade 已是
// supervisord 管的常驻 daemon, 本脚本"部署策略" = 写策略文件 +
// 改 config.strategy + 重启 daemon. 不再 git clone freqtrade,
// 不再 nohup 后台进程, 不跟 daemon 抢 8080 端口.
// - host 模式 (用户本地 macOS / Linux): 沿用老路径, 自己 clone freqtrade,
// 起后台进程, 写 PID file. 这条路在 coinclaw 之外仍然有效.
//
// coinclaw 模式下 strategy / backtest / 配置变更 都通过容器里预装的
// freqtrade CLI + freqtrade REST API 完成, 跟 dashboard 看到的状态保持一致.
import {
readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync,
readdirSync, renameSync, chmodSync,
} from 'node:fs';
import { resolve, dirname } from 'node:path';
import { execSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import {
coinclawEnv, hostModeFreqtradePaths, envFileCandidates, supervisorSocket,
} from '../lib/coinclaw-env.mjs';
import { ftGet, ftPost } from '../lib/freqtrade-api.mjs';
import {
buildStrategyCode, SAMPLE_STRATEGY,
AVAILABLE_INDICATORS, AVAILABLE_AICOIN_DATA, PAID_DATA,
} from '../lib/strategy-builder.mjs';
const __dir = dirname(fileURLToPath(import.meta.url));
// ─── 模式 / 路径解析 ─────────────────────────────────────────────
const ENV = coinclawEnv();
const HOST = hostModeFreqtradePaths();
// 三引擎下 STRAT_DIR / USER_DATA / CONFIG_PATH 直接来自 daemon 启动参数,
// 跟 dashboard / freqtrade /api/v1/show_config 保持完全一致 — 不会出现
// "agent 写到 ~/.freqtrade/user_data/strategies/ 但 daemon 不读" 这种坑.
const STRAT_DIR = ENV ? ENV.strategyPath : HOST.strategyPath;
const USER_DATA = ENV ? ENV.freqtradeUserdir : HOST.userdir;
const CONFIG_PATH = ENV ? ENV.configPath : HOST.configPath;
const ENV_FILE = ENV ? ENV.envFile : envFileCandidates()[0]; // host: ~/.coinos/.env(规范位置, 与读路径最高优先级一致)
// FT_BIN 解析顺序:
// 1. coinclaw 容器: 'freqtrade' — image PATH 上已经有 (entrypoint
// ENV PATH 包含 /home/node/.freqtrade/source/.venv/bin 或者
// ftuser 的 ~/.local/bin), 直接用最干净.
// 2. host 模式优先 `command -v freqtrade` — 用户本地已经装过的
// 系统 freqtrade (brew / uv / 系统包) 直接复用. 老版本 ft-deploy
// 会 git clone freqtrade 重装一次 setup.sh, 多等几分钟 + 多占
// ~500MB. 见 commit 50011b8.
// 3. host fallback: ~/.freqtrade/source/.venv/bin/freqtrade — 真
// 没有时才走 setup.sh 装到 venv.
const FT_BIN = ENV ? 'freqtrade' : (() => {
try {
const sys = execSync('command -v freqtrade', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
if (sys && existsSync(sys)) return sys;
} catch {}
return HOST.ftBin;
})();
// ─── 通用辅助 ─────────────────────────────────────────────────────
function run(cmd, opts = {}) {
return execSync(cmd, { encoding: 'utf-8', timeout: 600000, ...opts }).trim();
}
function hasCommand(cmd) {
try { run(`which ${cmd}`); return true; } catch { return false; }
}
// 轻量 env 读取 — freqtrade-api.mjs 已经 loadEnv() 一次, 这里是为了 host
// 模式下的 detectExchange / appendEnv 等动作能拿到最新值.
function loadEnv() {
for (const file of envFileCandidates()) {
if (!existsSync(file)) continue;
try {
for (const line of readFileSync(file, 'utf-8').split('\n')) {
const t = line.trim();
if (!t || t.startsWith('#')) continue;
const eq = t.indexOf('=');
if (eq < 1) continue;
const key = t.slice(0, eq).trim();
let val = t.slice(eq + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) val = val.slice(1, -1);
if (!process.env[key]) process.env[key] = val;
}
} catch {}
}
}
loadEnv();
function appendEnv(key, val) {
try { mkdirSync(dirname(ENV_FILE), { recursive: true }); } catch {} // ~/.coinos 可能还不存在
if (!existsSync(ENV_FILE)) {
writeFileSync(ENV_FILE, `${key}=${val}\n`);
try { chmodSync(ENV_FILE, 0o600); } catch {}
return;
}
const content = readFileSync(ENV_FILE, 'utf-8');
const lines = content.split('\n');
const idx = lines.findIndex((l) => l.trim().startsWith(`${key}=`));
if (idx >= 0) {
lines[idx] = `${key}=${val}`;
writeFileSync(ENV_FILE, lines.join('\n'));
} else {
writeFileSync(ENV_FILE, content.trimEnd() + `\n${key}=${val}\n`);
}
}
// 在三引擎容器里 .env 同时承载交易所 key + AiCoin key + DRY_RUN +
// SELECTED_EXCHANGE, agent 直接告诉用户去 EnvSection 改, 不在脚本里写.
// host 模式下沿用老的"自己 nohup freqtrade"流程, 才需要这个 detectExchange.
function detectExchange() {
const exchanges = ['BINANCE', 'OKX', 'BYBIT', 'BITGET', 'GATE', 'HTX', 'KUCOIN', 'MEXC'];
for (const ex of exchanges) {
if (process.env[`${ex}_API_KEY`] && process.env[`${ex}_API_SECRET`]) {
return {
name: ex.toLowerCase(),
key: process.env[`${ex}_API_KEY`],
secret: process.env[`${ex}_API_SECRET`],
password: process.env[`${ex}_PASSWORD`] || '',
};
}
}
return null;
}
// ─── coinclaw 模式: daemon 操作 ──────────────────────────────────
function readDaemonConfig() {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
}
function writeDaemonConfig(cfg) {
const bak = `${CONFIG_PATH}.bak`;
copyFileSync(CONFIG_PATH, bak);
try { chmodSync(bak, 0o600); } catch {}
const tmp = `${CONFIG_PATH}.tmp.${process.pid}`;
writeFileSync(tmp, JSON.stringify(cfg, null, 4) + '\n');
try { chmodSync(tmp, 0o600); } catch {}
renameSync(tmp, CONFIG_PATH);
try { chmodSync(CONFIG_PATH, 0o600); } catch {}
}
function restartDaemon() {
if (!ENV) throw new Error('restart daemon 仅在 coinclaw 容器内可用');
const sock = supervisorSocket();
try {
execSync(`supervisorctl -s unix://${sock} restart freqtrade`, {
stdio: 'pipe', timeout: 30000,
});
return { method: 'supervisorctl' };
} catch (e) {
try {
const pid = run("pgrep -f 'freqtrade trade' | head -n1");
if (pid) {
process.kill(Number(pid), 'SIGTERM');
return { method: 'kill+autorestart', pid: Number(pid) };
}
} catch {}
throw new Error(`restart 失败: ${e.message}`);
}
}
// 通过 dump+grep ps 拿 daemon 当前用的 strategy / pair_whitelist 等运行
// 时配置. /api/v1/show_config 是最稳的来源, 跟 freqtrade UI/dashboard 一致.
async function fetchDaemonState() {
try {
const cfg = await ftGet('show_config');
return { online: true, ...cfg };
} catch (e) {
return { online: false, error: e.message };
}
}
// ─── host 模式: 自己管 freqtrade 进程 ───────────────────────────
function getHostPid() {
if (!HOST.pidFile || !existsSync(HOST.pidFile)) return null;
const pid = readFileSync(HOST.pidFile, 'utf-8').trim();
if (!pid) return null;
try { process.kill(Number(pid), 0); return Number(pid); } catch { return null; }
}
function findPython() {
const names = ['python3.13', 'python3.12', 'python3.11', 'python3'];
const extraDirs = ['/opt/homebrew/bin', '/usr/local/bin', `${process.env.HOME}/.local/bin`];
const candidates = [...names];
for (const dir of extraDirs) {
for (const n of names.slice(0, 3)) candidates.push(resolve(dir, n));
}
for (const bin of candidates) {
try {
const version = run(`${bin} --version`);
const match = version.match(/(\d+)\.(\d+)/);
if (match) {
const major = Number(match[1]); const minor = Number(match[2]);
if (major === 3 && minor >= 11) return { bin, major, minor, version };
}
} catch {}
}
return null;
}
function ensureModernPython() {
let py = findPython();
if (py) return py;
if (process.platform === 'darwin') {
try {
const uvBin = resolve(process.env.HOME || '', '.local', 'bin', 'uv');
if (!existsSync(uvBin)) {
console.error('Installing uv (fast Python manager)...');
run('curl -LsSf https://astral.sh/uv/install.sh | sh', { timeout: 60000 });
}
if (existsSync(uvBin)) {
console.error('Installing Python 3.12 via uv...');
run(`${uvBin} python install 3.12`, { timeout: 300000 });
try {
const pyPath = run(`${uvBin} python find 3.12`);
if (pyPath) {
const ver = run(`${pyPath} --version`);
const m = ver.match(/(\d+)\.(\d+)/);
if (m && Number(m[1]) === 3 && Number(m[2]) >= 11) {
return { bin: pyPath, major: Number(m[1]), minor: Number(m[2]), version: ver };
}
}
} catch {}
}
} catch (e) { console.error(`uv: ${e.message}`); }
try {
if (hasCommand('brew')) {
console.error('Trying brew install python@3.12...');
const brewEnv = { ...process.env, HOMEBREW_NO_AUTO_UPDATE: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1' };
run('brew install python@3.12', { timeout: 300000, env: brewEnv });
py = findPython();
if (py) return py;
}
} catch (e) { console.error(`brew: ${e.message}`); }
}
throw new Error('Python 3.11+ required. Install options:\n• curl -LsSf https://astral.sh/uv/install.sh | sh && uv python install 3.12\n• brew install python@3.12\n• https://www.python.org/downloads/');
}
function generateHostConfig(exchangeInfo, apiPassword, params = {}) {
const config = {
trading_mode: params.trading_mode || 'futures',
margin_mode: params.margin_mode || 'isolated',
max_open_trades: params.max_open_trades || 3,
stake_currency: 'USDT',
stake_amount: params.stake_amount || 'unlimited',
tradable_balance_ratio: params.tradable_balance_ratio || 0.5,
dry_run: params.dry_run !== false,
dry_run_wallet: 1000,
cancel_open_orders_on_exit: false,
exchange: {
name: exchangeInfo.name,
key: exchangeInfo.key,
secret: exchangeInfo.secret,
...(exchangeInfo.password ? { password: exchangeInfo.password } : {}),
ccxt_config: {},
ccxt_async_config: {},
pair_whitelist: params.pairs || ['BTC/USDT:USDT', 'ETH/USDT:USDT'],
pair_blacklist: [],
},
pairlists: [{ method: 'StaticPairList' }],
entry_pricing: { price_side: 'same', use_order_book: true, order_book_top: 1 },
exit_pricing: { price_side: 'same', use_order_book: true, order_book_top: 1 },
api_server: {
enabled: true,
listen_ip_address: '127.0.0.1',
listen_port: 8080,
verbosity: 'error',
enable_openapi: false,
jwt_secret_key: randomBytes(16).toString('hex'),
CORS_origins: [],
// freqtrade 三引擎容器里 daemon user 都是 'freqtrade', host 模式跟齐 —
// 老版本默认 'freqtrader' 跟容器不一致, 历史 bug.
username: 'freqtrade',
password: apiPassword,
},
bot_name: 'aicoin-freqtrade',
initial_state: 'running',
force_entry_enable: true,
internals: { process_throttle_secs: 5 },
};
const proxyUrl = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
if (proxyUrl) {
config.exchange.ccxt_config.proxies = { https: proxyUrl, http: proxyUrl };
config.exchange.ccxt_async_config.aiohttp_proxy = proxyUrl;
config.exchange.enable_ws = false;
}
return config;
}
// ─── 公共: 复制 AiCoin SDK + 模板策略到 strategy 目录 ────────────
// 三引擎容器 image 已经在 build time 把 aicoin_data.py 复制到了 strategy
// 目录(image-*/Dockerfile + image/Dockerfile), 所以 coinclaw 模式下这一步
// 是幂等的 no-op, 但留着保证 host 模式 + agent 第一次 create_strategy 时
// 能拿到 SDK.
function ensureSdkAndTemplates() {
mkdirSync(STRAT_DIR, { recursive: true });
const skillDir = resolve(__dir, '..');
const sdkSrc = resolve(skillDir, 'lib', 'aicoin_data.py');
const defaultsSrc = resolve(skillDir, 'lib', 'defaults.json');
const strategiesSrc = resolve(skillDir, 'strategies');
if (existsSync(sdkSrc)) {
const sdkDest = resolve(STRAT_DIR, 'aicoin_data.py');
if (!existsSync(sdkDest)) copyFileSync(sdkSrc, sdkDest);
}
if (existsSync(defaultsSrc)) {
const dDest = resolve(STRAT_DIR, 'defaults.json');
if (!existsSync(dDest)) copyFileSync(defaultsSrc, dDest);
}
if (existsSync(strategiesSrc)) {
for (const f of readdirSync(strategiesSrc)) {
if (f.endsWith('.py')) {
const dest = resolve(STRAT_DIR, f);
if (!existsSync(dest)) copyFileSync(resolve(strategiesSrc, f), dest);
}
}
}
}
// ─── Actions ─────────────────────────────────────────────────────
const actions = {
// ── check ──────────────────────────────────────────────────────
// coinclaw 模式: ping daemon + show_config + balance.
// host 模式: 检查 python / git / freqtrade installed / pid.
check: async () => {
if (ENV) {
const checks = { mode: 'coinclaw', engine: ENV.engine, paths: {
userdir: USER_DATA, strategy_path: STRAT_DIR, config: CONFIG_PATH,
}};
const state = await fetchDaemonState();
checks.daemon_online = state.online;
if (state.online) {
checks.strategy = state.strategy;
checks.exchange = state.exchange;
checks.dry_run = state.dry_run;
checks.timeframe = state.timeframe;
checks.trading_mode = state.trading_mode;
try {
const bal = await ftGet('balance');
checks.total = bal.total;
checks.starting_capital = bal.starting_capital;
checks.stake_currency = bal.stake;
} catch (e) { checks.balance_error = e.message; }
} else {
checks.note = '在 coinclaw 容器里 daemon 由 supervisord 管理, 它没起来通常是 cold-start 卡住或 config 写错; 看 /workspace/logs/freqtrade-error.log 或 /home/node/.openclaw/workspace/.freqtrade/logs/';
}
return checks;
}
// host mode
const checks = { mode: 'host' };
const py = findPython();
checks.python = py ? `${py.version} (${py.bin})` : false;
if (!py) {
try {
const v = run('python3 --version');
checks.python_warning = `${v} found but Freqtrade requires 3.11+. Deploy will auto-install 3.12.`;
} catch {}
}
checks.git = hasCommand('git');
checks.source_cloned = existsSync(resolve(HOST.sourceDir, 'setup.sh'));
checks.freqtrade_installed = existsSync(FT_BIN);
if (checks.freqtrade_installed) {
try { checks.freqtrade_version = run(`${FT_BIN} --version`); } catch {}
}
const ex = detectExchange();
checks.exchange = ex ? { name: ex.name, configured: true } : { configured: false };
const pid = getHostPid();
checks.running = !!pid;
if (pid) checks.pid = pid;
checks.ready = (!!py || process.platform === 'darwin') && checks.git && checks.exchange?.configured;
if (!checks.ready) {
checks.missing = [];
if (!py && process.platform !== 'darwin') checks.missing.push('Python 3.11+ not found');
if (!checks.git) checks.missing.push('git not found');
if (!checks.exchange?.configured) checks.missing.push('No exchange API keys in .env');
}
return checks;
},
// ── deploy ─────────────────────────────────────────────────────
// coinclaw 模式: 写策略 (如果 caller 已 create_strategy 就是 no-op) +
// 改 config.strategy + 重启 daemon. 不再 git clone, 不再 nohup.
// host 模式: 沿用老路径 (clone + setup.sh + nohup).
deploy: async (params = {}) => {
if (ENV) {
const strategy = params.strategy;
if (!strategy) throw new Error('strategy 必填, 例: {"strategy":"MyStrat"}');
const stratFile = resolve(STRAT_DIR, `${strategy}.py`);
if (!existsSync(stratFile)) {
throw new Error(`策略文件不存在: ${stratFile}. 先用 ft-deploy.mjs create_strategy 或 Write 工具写文件到 ${STRAT_DIR}/`);
}
const cfg = readDaemonConfig();
const before = { strategy: cfg.strategy, dry_run: cfg.dry_run, pairs: cfg.exchange?.pair_whitelist };
cfg.strategy = strategy;
// 允许在 deploy 里同时改 dry_run / pairs / max_open_trades, 一次完成.
if (typeof params.dry_run === 'boolean') cfg.dry_run = params.dry_run;
if (Array.isArray(params.pairs) && params.pairs.length) {
if (!cfg.exchange) cfg.exchange = {};
cfg.exchange.pair_whitelist = params.pairs;
}
if (params.max_open_trades) cfg.max_open_trades = params.max_open_trades;
writeDaemonConfig(cfg);
const restart = restartDaemon();
return {
success: true, mode: 'coinclaw', engine: ENV.engine,
strategy, before, restart,
config_path: CONFIG_PATH, strategy_file: stratFile,
note: '策略生效需 daemon 重启完成 (10-30s); dashboard 会自动刷新到新策略名',
warning: cfg.dry_run === false
? '⚠️ 已切到实盘 — 真实交易, 真实亏损. 确认 .env 里交易所 key 正确, 余额可控.'
: null,
};
}
// host mode (老逻辑, 保留不动)
const py = ensureModernPython();
console.error(`Using ${py.version} (${py.bin})`);
if (!hasCommand('git')) throw new Error('git not found.');
let exchangeInfo = detectExchange();
if (!exchangeInfo) {
if (params.dry_run !== false) {
const exName = params.exchange || 'binance';
exchangeInfo = { name: exName, key: 'dry-run', secret: 'dry-run' };
console.error(`No exchange API keys found — using dummy keys for dry-run (${exName})`);
} else {
throw new Error('No exchange API keys found in .env (required for live trading)');
}
}
mkdirSync(STRAT_DIR, { recursive: true });
ensureSdkAndTemplates();
if (!existsSync(FT_BIN)) {
if (!existsSync(resolve(HOST.sourceDir, 'setup.sh'))) {
console.error('Cloning Freqtrade repository...');
run(`git clone https://github.com/freqtrade/freqtrade.git ${HOST.sourceDir}`, { timeout: 120000 });
run(`cd ${HOST.sourceDir} && git checkout stable`, { timeout: 30000 });
}
console.error('Running Freqtrade setup.sh (this may take a few minutes)...');
const pyDir = dirname(py.bin);
const setupEnv = { ...process.env, PATH: `${pyDir}:${process.env.PATH}` };
run(`cd ${HOST.sourceDir} && ./setup.sh -i`, { timeout: 600000, env: setupEnv });
if (!existsSync(FT_BIN)) throw new Error('Freqtrade installation failed.');
}
const apiPassword = randomBytes(8).toString('hex');
const config = generateHostConfig(exchangeInfo, apiPassword, params);
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
try { chmodSync(CONFIG_PATH, 0o600); } catch {} // config.json 含明文交易所 key/secret, 收紧权限
const samplePath = resolve(STRAT_DIR, 'SampleStrategy.py');
if (!existsSync(samplePath)) writeFileSync(samplePath, SAMPLE_STRATEGY);
const oldPid = getHostPid();
if (oldPid) { try { process.kill(oldPid, 'SIGTERM'); } catch {} }
const strategy = params.strategy || 'SampleStrategy';
const stratFile = resolve(STRAT_DIR, `${strategy}.py`);
if (strategy !== 'SampleStrategy' && !existsSync(stratFile)) {
throw new Error(`Strategy "${strategy}" not found at ${stratFile}. Use create_strategy first.`);
}
const proxyEnv = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const proxyPrefix = proxyEnv ? `env HTTPS_PROXY=${proxyEnv} HTTP_PROXY=${proxyEnv} ` : '';
run(`nohup ${proxyPrefix}${FT_BIN} trade --config ${CONFIG_PATH} --strategy ${strategy} --userdir ${USER_DATA} > ${HOST.logFile} 2>&1 & echo $! > ${HOST.pidFile}`);
let ready = false;
for (let i = 0; i < 15; i++) {
await new Promise((r) => setTimeout(r, 2000));
const pid = getHostPid();
if (pid) {
try {
const res = await fetch(`http://127.0.0.1:8080/api/v1/ping`, {
headers: { Authorization: 'Basic ' + Buffer.from(`freqtrade:${apiPassword}`).toString('base64') },
signal: AbortSignal.timeout(3000),
});
if (res.ok) { ready = true; break; }
} catch {}
}
}
appendEnv('FREQTRADE_URL', 'http://127.0.0.1:8080');
appendEnv('FREQTRADE_USERNAME', 'freqtrade');
appendEnv('FREQTRADE_PASSWORD', apiPassword);
return {
success: true, mode: 'host',
exchange: exchangeInfo.name, strategy, dry_run: config.dry_run,
pairs: config.exchange.pair_whitelist,
api_url: 'http://127.0.0.1:8080', api_auth: 'stored in .env (FREQTRADE_PASSWORD)',
pid: getHostPid(), ready, log_file: HOST.logFile, config_path: CONFIG_PATH,
strategies_dir: STRAT_DIR,
note: config.dry_run ? 'Running in DRY-RUN mode' : 'WARNING: Running in LIVE mode',
};
},
// ── update ─────────────────────────────────────────────────────
update: async () => {
if (ENV) {
return {
skipped: true, mode: 'coinclaw',
note: '在 coinclaw 容器里 freqtrade 由 image 预装, 升级请 helm upgrade 整个 instance (web 端有"升级"按钮), 不能在容器里 git pull',
};
}
if (!existsSync(resolve(HOST.sourceDir, 'setup.sh'))) {
return { error: 'Freqtrade not installed. Run deploy first.' };
}
const pid = getHostPid();
if (pid) { try { process.kill(pid, 'SIGTERM'); } catch {} }
console.error('Updating Freqtrade...');
run(`cd ${HOST.sourceDir} && ./setup.sh -u`, { timeout: 600000 });
return { updated: true, mode: 'host', note: 'Run start to restart Freqtrade.' };
},
// ── status ─────────────────────────────────────────────────────
status: async () => {
if (ENV) {
const state = await fetchDaemonState();
const result = { mode: 'coinclaw', engine: ENV.engine, ...state };
// tail freqtrade 日志, 三引擎日志位置不同.
const logCandidates = [
'/workspace/logs/freqtrade.log',
'/workspace/logs/freqtrade-error.log',
];
for (const log of logCandidates) {
if (existsSync(log)) {
try { result.last_logs = run(`tail -10 ${log}`); break; } catch {}
}
}
return result;
}
const pid = getHostPid();
if (!pid) return { mode: 'host', running: false };
let lastLogs = '';
try { lastLogs = run(`tail -5 ${HOST.logFile} 2>/dev/null`); } catch {}
return { mode: 'host', running: true, pid, log_file: HOST.logFile, last_logs: lastLogs };
},
// ── stop / start ───────────────────────────────────────────────
// coinclaw 模式: supervisorctl. host 模式: SIGTERM pid.
stop: async () => {
if (ENV) {
const sock = supervisorSocket();
try {
run(`supervisorctl -s unix://${sock} stop freqtrade`);
return { stopped: true, mode: 'coinclaw', method: 'supervisorctl' };
} catch (e) {
return { stopped: false, error: e.message, note: 'supervisorctl 不可达, 试试 ft.mjs stop (REST)' };
}
}
const pid = getHostPid();
if (!pid) return { stopped: false, mode: 'host', reason: 'Not running' };
try { process.kill(pid, 'SIGTERM'); } catch {}
try { writeFileSync(HOST.pidFile, ''); } catch {}
return { stopped: true, mode: 'host', pid };
},
start: async (params = {}) => {
if (ENV) {
const sock = supervisorSocket();
try {
run(`supervisorctl -s unix://${sock} start freqtrade`);
return { started: true, mode: 'coinclaw', method: 'supervisorctl' };
} catch (e) {
return { started: false, error: e.message };
}
}
if (getHostPid()) return { started: false, mode: 'host', reason: 'Already running' };
if (!existsSync(FT_BIN)) throw new Error('Freqtrade not installed. Run deploy first.');
if (!existsSync(CONFIG_PATH)) throw new Error('No config found. Run deploy first.');
const strategy = params.strategy || 'SampleStrategy';
const proxyUrl = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const proxyPrefix = proxyUrl ? `env HTTPS_PROXY=${proxyUrl} HTTP_PROXY=${proxyUrl} ` : '';
run(`nohup ${proxyPrefix}${FT_BIN} trade --config ${CONFIG_PATH} --strategy ${strategy} --userdir ${USER_DATA} > ${HOST.logFile} 2>&1 & echo $! > ${HOST.pidFile}`);
await new Promise((r) => setTimeout(r, 3000));
return { started: true, mode: 'host', pid: getHostPid() };
},
// ── logs ───────────────────────────────────────────────────────
// coinclaw 模式: tail /workspace/logs/freqtrade.log (supervisord 写在那).
// host 模式: tail freqtrade.log.
logs: async ({ lines = 50 } = {}) => {
if (ENV) {
for (const log of ['/workspace/logs/freqtrade.log', '/workspace/logs/freqtrade-error.log']) {
if (existsSync(log)) {
try { return { mode: 'coinclaw', log_file: log, logs: run(`tail -${lines} ${log}`) }; } catch {}
}
}
return { mode: 'coinclaw', logs: '(no log file found in /workspace/logs)' };
}
try { return { mode: 'host', logs: run(`tail -${lines} ${HOST.logFile} 2>/dev/null`) }; }
catch { return { mode: 'host', logs: 'No log file found' }; }
},
// ── backtest ───────────────────────────────────────────────────
// 两边都用 freqtrade backtesting CLI; 区别只在路径.
// coinclaw 模式跑 backtest 不影响 daemon: backtesting 走自己的进程,
// 跟 daemon 共用 user_data 但不共用 :8080.
backtest: async (params = {}) => {
if (!existsSync(FT_BIN) && !ENV) throw new Error('Freqtrade not installed. Run deploy first.');
if (!existsSync(CONFIG_PATH)) {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
const exchange = params.exchange || 'binance';
const cfg = generateHostConfig(
{ name: exchange, key: 'backtest-only', secret: 'backtest-only' },
randomBytes(8).toString('hex'),
{ dry_run: true, pairs: params.pairs || ['BTC/USDT:USDT'] },
);
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
console.error(`Auto-created backtest config (exchange: ${exchange})`);
}
const strategy = params.strategy || 'SampleStrategy';
const stratFile = resolve(STRAT_DIR, `${strategy}.py`);
if (!existsSync(stratFile)) {
throw new Error(`Strategy "${strategy}" not found at ${stratFile}. Use create_strategy or list with strategy_list.`);
}
const timeframe = params.timeframe || '1h';
const timerange = params.timerange || '';
const timerangeArg = timerange ? ` --timerange ${timerange}` : '';
const pairs = params.pairs;
const pairsArg = pairs ? ` -p ${(Array.isArray(pairs) ? pairs : [pairs]).join(' ')}` : '';
const proxyEnv = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const proxyPrefix = proxyEnv ? `env HTTPS_PROXY=${proxyEnv} HTTP_PROXY=${proxyEnv} ` : '';
console.error('Downloading historical data...');
try {
run(
`${proxyPrefix}${FT_BIN} download-data --config ${CONFIG_PATH} --timeframe ${timeframe}${timerangeArg}${pairsArg} --userdir ${USER_DATA}`,
{ timeout: 300000 }
);
} catch (e) {
console.error(`Data download warning: ${e.message}`);
}
console.error(`Running backtest: strategy=${strategy}, timeframe=${timeframe}${timerange ? `, timerange=${timerange}` : ''}...`);
const rawOutput = run(
`${proxyPrefix}${FT_BIN} backtesting --config ${CONFIG_PATH} --strategy ${strategy} --strategy-path ${STRAT_DIR} --timeframe ${timeframe}${timerangeArg}${pairsArg} --userdir ${USER_DATA}`,
{ timeout: 600000 }
);
const output = rawOutput
.split('\n')
.filter((l) => !l.includes('INFO') || l.includes('TOTAL') || l.includes('Result') || l.includes('trades') || l.includes('Profit') || l.includes('Drawdown') || l.includes('Win') || l.includes('Avg'))
.join('\n')
.replace(/\b127\.0\.0\.1:\d+\b/g, '[local]')
.replace(/https?:\/\/\d+\.\d+\.\d+\.\d+:\d+/g, '[proxy]');
return { mode: ENV ? 'coinclaw' : 'host', strategy, timeframe, timerange: timerange || 'all available', output };
},
// ── download_data ──────────────────────────────────────────────
download_data: async (params = {}) => {
if (!existsSync(FT_BIN) && !ENV) throw new Error('Freqtrade not installed. Run deploy first.');
if (!existsSync(CONFIG_PATH)) {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
const exchange = params.exchange || 'binance';
const cfg = generateHostConfig(
{ name: exchange, key: 'download-only', secret: 'download-only' },
randomBytes(8).toString('hex'),
{ dry_run: true, pairs: params.pairs || ['BTC/USDT:USDT'] },
);
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
}
const timeframe = params.timeframe || '1h';
const timerange = params.timerange || '';
const timerangeArg = timerange ? ` --timerange ${timerange}` : '';
const proxyEnv = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const proxyPrefix = proxyEnv ? `env HTTPS_PROXY=${proxyEnv} HTTP_PROXY=${proxyEnv} ` : '';
console.error(`Downloading data: timeframe=${timeframe}${timerange ? `, timerange=${timerange}` : ''}...`);
const output = run(
`${proxyPrefix}${FT_BIN} download-data --config ${CONFIG_PATH} --timeframe ${timeframe}${timerangeArg} --userdir ${USER_DATA}`,
{ timeout: 300000 }
);
return { mode: ENV ? 'coinclaw' : 'host', timeframe, timerange: timerange || 'all available', output };
},
// ── hyperopt ───────────────────────────────────────────────────
hyperopt: async (params = {}) => {
if (!existsSync(FT_BIN) && !ENV) throw new Error('Freqtrade not installed.');
if (!existsSync(CONFIG_PATH)) {
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
const exchange = params.exchange || 'binance';
const cfg = generateHostConfig(
{ name: exchange, key: 'hyperopt-only', secret: 'hyperopt-only' },
randomBytes(8).toString('hex'),
{ dry_run: true, pairs: params.pairs || ['BTC/USDT:USDT'] },
);
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2));
}
const strategy = params.strategy || 'SampleStrategy';
const stratFile = resolve(STRAT_DIR, `${strategy}.py`);
if (!existsSync(stratFile)) throw new Error(`Strategy "${strategy}" not found at ${stratFile}.`);
const timeframe = params.timeframe || '1h';
const timerange = params.timerange || '';
const epochs = Math.min(Number(params.epochs) || 100, 500);
const spaces = params.spaces || 'roi stoploss trailing buy sell';
const jobs = Math.min(Number(params.jobs) || 1, 4);
const lossFunc = params.loss || 'SharpeHyperOptLoss';
const minTrades = params.min_trades || 20;
const timerangeArg = timerange ? ` --timerange ${timerange}` : '';
const proxyEnv = process.env.PROXY_URL || process.env.HTTPS_PROXY || process.env.HTTP_PROXY;
const proxyPrefix = proxyEnv ? `env HTTPS_PROXY=${proxyEnv} HTTP_PROXY=${proxyEnv} ` : '';
try {
run(
`${proxyPrefix}${FT_BIN} download-data --config ${CONFIG_PATH} --timeframe ${timeframe}${timerangeArg} --userdir ${USER_DATA}`,
{ timeout: 300000 }
);
} catch (e) { console.error(`Data download warning: ${e.message}`); }
console.error(`Running hyperopt: strategy=${strategy}, epochs=${epochs}, jobs=${jobs}, spaces=${spaces}`);
const output = run(
`${proxyPrefix}${FT_BIN} hyperopt --config ${CONFIG_PATH} --strategy ${strategy} --strategy-path ${STRAT_DIR} --timeframe ${timeframe}${timerangeArg} --userdir ${USER_DATA} --hyperopt-loss ${lossFunc} --spaces ${spaces} --epochs ${epochs} -j ${jobs} --min-trades ${minTrades}`,
{ timeout: 1800000 }
);
return { mode: ENV ? 'coinclaw' : 'host', strategy, timeframe, epochs, spaces, jobs, loss_function: lossFunc, output };
},
// ── create_strategy ────────────────────────────────────────────
// 写策略文件到 STRAT_DIR (三引擎下分别是 daemon 真读的路径).
create_strategy: async (params = {}) => {
let name = params.name;
if (!name) throw new Error('name is required. Example: {"name":"MyStrategy","timeframe":"15m","indicators":["rsi","macd"],"aicoin_data":["funding_rate"]}');
name = name.replace(/[^A-Za-z0-9_]/g, '');
if (name && /^[a-z]/.test(name)) name = name[0].toUpperCase() + name.slice(1);
if (!/^[A-Z][A-Za-z0-9_]+$/.test(name)) throw new Error('name must be a valid Python class name starting with uppercase (e.g. MyStrategy)');
ensureSdkAndTemplates();
const dest = resolve(STRAT_DIR, `${name}.py`);
const tf = params.timeframe || '15m';
const desc = params.description || 'Custom strategy';
const ds = new Set(params.aicoin_data || []);
const indicators = params.indicators || null;
const entryLogic = params.entry_logic || null;
const exitLogic = params.exit_logic || null;
const direction = params.direction || 'long';
if (!['long', 'short', 'both'].includes(direction)) {
throw new Error(`direction must be "long", "short", or "both" (default: "long")`);
}
if (indicators) {
const invalid = indicators.filter((i) => !AVAILABLE_INDICATORS.includes(i.toLowerCase()));
if (invalid.length > 0) {
throw new Error(`Unknown indicators: ${invalid.join(', ')}. Available: ${AVAILABLE_INDICATORS.join(', ')}`);
}
}
const KEY = process.env.AICOIN_ACCESS_KEY_ID || '';
const defaultKey = JSON.parse(readFileSync(resolve(__dir, '..', 'lib', 'defaults.json'), 'utf-8')).accessKeyId || '';
const usingFreeKey = !KEY || KEY === defaultKey;
const paidUsed = [...ds].filter((d) => d in PAID_DATA);
const code = buildStrategyCode(name, tf, desc, ds, indicators, entryLogic, exitLogic, direction);
writeFileSync(dest, code);
const result = {
success: true, strategy: name, file: dest,
mode: ENV ? 'coinclaw' : 'host', engine: ENV ? ENV.engine : null,
timeframe: tf, direction,
indicators: indicators || ['rsi', 'bb', 'ema', 'volume_sma'],
aicoin_data: [...ds],
note: ds.size
? `Strategy uses AiCoin data (${[...ds].join(', ')}) in live/dry_run. Falls back to pure technical indicators in backtest.`
: 'Pure technical indicator strategy. To add AiCoin data, pass aicoin_data array.',
next: ENV
? `策略文件已写; 用 deploy {"strategy":"${name}"} 让常驻 daemon 切到这个策略 (会触发 ~30s 重启), 或先 backtest 验证`
: `Use deploy {"strategy":"${name}"} to start in dry-run, or backtest first`,
available_indicators: AVAILABLE_INDICATORS,
available_aicoin_data: AVAILABLE_AICOIN_DATA,
};
if (usingFreeKey && paidUsed.length > 0) {
result.warning = `PAID KEY REQUIRED — Strategy uses ${paidUsed.map((d) => `${d} (${PAID_DATA[d]})`).join(', ')} but no paid API key is configured. These data sources will silently fall back to defaults in live mode. Get key at https://www.aicoin.com/opendata → add AICOIN_ACCESS_KEY_ID & AICOIN_ACCESS_SECRET to .env.`;
}
return result;
},
// ── strategy_list ──────────────────────────────────────────────
strategy_list: async () => {
const files = [];
if (existsSync(STRAT_DIR)) {
for (const f of readdirSync(STRAT_DIR)) {
if (f.endsWith('.py') && f !== '__init__.py' && f !== 'aicoin_data.py') {
files.push(f.replace('.py', ''));
}
}
}
return { mode: ENV ? 'coinclaw' : 'host', strategies: files, path: STRAT_DIR };
},
// ── remove ─────────────────────────────────────────────────────
remove: async () => {
if (ENV) {
return {
skipped: true, mode: 'coinclaw',
note: '在 coinclaw 容器里 freqtrade 是常驻 daemon, 不能 remove. 用 stop 停 daemon, 或 deploy {"strategy":"NoOpStrategy"} 切到空跑策略, 或在 web UI 删整个 instance',
};
}
const pid = getHostPid();
if (pid) { try { process.kill(pid, 'SIGTERM'); } catch {} }
try { writeFileSync(HOST.pidFile, ''); } catch {}
return { removed: true, mode: 'host', note: `Process stopped. Config preserved.` };
},
// ── backtest_results ───────────────────────────────────────────
backtest_results: async () => {
const resultsDir = resolve(USER_DATA, 'backtest_results');
if (!existsSync(resultsDir)) return { mode: ENV ? 'coinclaw' : 'host', results: [], path: resultsDir };
const files = readdirSync(resultsDir)
.filter((f) => f.endsWith('.meta.json'))
.map((f) => {
try {
const meta = JSON.parse(readFileSync(resolve(resultsDir, f), 'utf-8'));
const strategy = Object.keys(meta)[0] || 'unknown';
const info = meta[strategy] || {};
return {
file: f.replace('.meta.json', ''),
strategy,
timeframe: info.timeframe || '',
start: info.backtest_start_ts ? new Date(info.backtest_start_ts * 1000).toISOString().slice(0, 10) : '',
end: info.backtest_end_ts ? new Date(info.backtest_end_ts * 1000).toISOString().slice(0, 10) : '',
};
} catch { return null; }
})
.filter(Boolean)
.sort((a, b) => b.file.localeCompare(a.file))
.slice(0, 10);
return { mode: ENV ? 'coinclaw' : 'host', results: files, path: resultsDir };
},
};
// ─── CLI ─────────────────────────────────────────────────────────
const [action, ...rest] = process.argv.slice(2);
if (!action || !actions[action]) {
console.log(`Usage: node ft-deploy.mjs <action> [json-params]\nActions: ${Object.keys(actions).join(', ')}`);
process.exit(1);
}
let params = {};
if (rest.length) {
try {
params = JSON.parse(rest.join(' '));
} catch {
console.log(JSON.stringify({
error: `参数不是合法 JSON: ${rest.join(' ')}`,
hint: "参数要用 JSON 对象, 例: '{\"strategy\":\"MyStrat\"}'",
}));
process.exit(1);
}
}
actions[action](params).then((r) => {
// 提示 — 只在 host 模式 / 老用法时强调走脚本; coinclaw 模式 daemon 已经
// 在 supervisord 管, 用户从 chat agent 调用脚本就是正确路径.
if (!ENV) r._reminder = 'IMPORTANT: Always use ft-deploy.mjs for ALL Freqtrade operations. NEVER use Docker commands.';
console.log(JSON.stringify(r, null, 2));
}).catch((e) => {
console.error(e.message);
process.exit(1);
});
#!/usr/bin/env node
// Freqtrade Dev Tools CLI
import { ftGet, ftPost, ftDelete, ftCli } from '../lib/freqtrade-api.mjs';
ftCli({
backtest_start: (p) => ftPost('backtest', p),
backtest_status: () => ftGet('backtest'),
backtest_abort: () => ftDelete('backtest'),
backtest_history: () => ftGet('backtest/history'),
backtest_result: ({ id }) => ftGet(`backtest/history/result`, { id }),
candles_live: ({ pair, timeframe, limit }) => ftGet('pair_candles', { pair, timeframe, limit }),
candles_analyzed: ({ pair, timeframe, strategy }) => ftGet('pair_history', { pair, timeframe, strategy }),
candles_available: () => ftGet('available_pairs'),
whitelist: () => ftGet('whitelist'),
blacklist: () => ftGet('blacklist'),
blacklist_add: ({ add }) => ftPost('blacklist', { blacklist: add }),
locks: () => ftGet('locks'),
strategy_list: () => ftGet('strategies'),
strategy_get: ({ name }) => ftGet(`strategy/${name}`),
});
#!/usr/bin/env node
// Freqtrade Bot Control CLI.
//
// 在 CoinClaw 三引擎容器里, freqtrade 是 supervisord 管的常驻 daemon —
// 不要自己起进程, 用本脚本通过 :8080 REST 控制. 切策略 / 切交易对 /
// 切实盘 / 重启 daemon 也都在这里.
import {
readFileSync, writeFileSync, existsSync, copyFileSync, renameSync, chmodSync,
} from 'node:fs';
import { execSync } from 'node:child_process';
import { ftGet, ftPost, ftDelete, ftCli } from '../lib/freqtrade-api.mjs';
import { coinclawEnv, supervisorSocket } from '../lib/coinclaw-env.mjs';
// ── 帮助函数: 读 / 改 daemon 的 config.json ───────────────────────────
// 三引擎下 config 路径不同, 通过 coinclaw-env 解析.
function configPath() {
const env = coinclawEnv();
if (!env) throw new Error('config 操作仅在 CoinClaw 容器内可用 (host 模式请用 ft-deploy.mjs deploy)');
return env.configPath;
}
function readConfig() {
return JSON.parse(readFileSync(configPath(), 'utf-8'));
}
function writeConfigAtomic(cfg) {
const path = configPath();
// 简单备份 — 改坏了 daemon autorestart 会一直 FATAL, 留一个 .bak 让 user 能 rollback.
// .bak 含明文交易所 key/secret, 必须 0600 收紧权限, 别让同机其它进程读到.
const bak = `${path}.bak`;
copyFileSync(path, bak);
chmodSync(bak, 0o600);
// 简单 atomic: 写到 tmp 再原地 rename (同目录 POSIX 原子, 不跨 fs 无 EXDEV).
// tmp 同样含明文 key/secret, 先 0600 再 rename.
const tmp = `${path}.tmp.${process.pid}`;
writeFileSync(tmp, JSON.stringify(cfg, null, 4) + '\n');
chmodSync(tmp, 0o600);
renameSync(tmp, path);
// rename 保留 tmp 的 mode, 但最终 config 显式再收紧一次以防万一.
chmodSync(path, 0o600);
}
// 重启 freqtrade daemon. 优先 supervisorctl (cleanest), 退到 kill 让
// supervisord autorestart 拉起. 仅 CoinClaw 容器内可用.
function restartDaemon() {
const env = coinclawEnv();
if (!env) throw new Error('restart 仅在 CoinClaw 容器内可用');
const sock = supervisorSocket();
// 1) 优先走 supervisorctl. 三引擎的 supervisord.conf 都把 freqtrade 注册为 program:freqtrade.
try {
execSync(`supervisorctl -s unix://${sock} restart freqtrade`, {
stdio: 'pipe', timeout: 30000,
});
return { method: 'supervisorctl', restarted: true };
} catch (e) {
// 2) 退到 kill freqtrade pid. supervisord autorestart=true / unexpected
// 都会重新拉起. 找 freqtrade 进程的 pid: pgrep -f 'freqtrade trade'.
try {
const pid = execSync("pgrep -f 'freqtrade trade' | head -n1", {
encoding: 'utf-8', timeout: 5000,
}).trim();
if (pid) {
process.kill(Number(pid), 'SIGTERM');
return { method: 'kill+autorestart', pid: Number(pid), restarted: true };
}
} catch {}
throw new Error(`restart 失败: supervisorctl 不可达 (${e.message}), 且没找到 freqtrade 进程`);
}
}
ftCli({
// ── 健康检查 / 信息查询 (REST GET) ──────────────────────────
ping: () => ftGet('ping'),
version: () => ftGet('version'),
sysinfo: () => ftGet('sysinfo'),
health: () => ftGet('health'),
config: () => ftGet('show_config'),
// ── daemon 综合信息 (一次拿状态 / 策略 / 模式 / 交易对) ───
// 给 agent 在用户问 "freqtrade 现在跑什么?" 时单次调用就能答全.
daemon_info: async () => {
const [cfg, status, version] = await Promise.all([
ftGet('show_config').catch((e) => ({ error: e.message })),
ftGet('status').catch(() => []),
ftGet('version').catch(() => ({})),
]);
return {
version: version.version,
strategy: cfg.strategy,
timeframe: cfg.timeframe,
exchange: cfg.exchange,
trading_mode: cfg.trading_mode,
dry_run: cfg.dry_run,
max_open_trades: cfg.max_open_trades,
stake_currency: cfg.stake_currency,
stake_amount: cfg.stake_amount,
pair_whitelist: cfg.whitelist || cfg.pair_whitelist,
bot_name: cfg.bot_name,
open_trades_count: Array.isArray(status) ? status.length : 0,
};
},
// ── daemon 状态控制 ────────────────────────────────────────
start: () => ftPost('start'),
stop: () => ftPost('stop'),
reload: () => ftPost('reload_config'),
// 重启整个 freqtrade 进程 — 切策略 / 切实盘必需 (reload_config 不切策略).
restart: async () => restartDaemon(),
// ── 配置变更 (改 config.json + reload 或 restart) ─────────
// 切策略: 改 config.strategy + 重启 daemon. 不能用 reload_config —
// freqtrade 1.x 的 reload_config 不重新加载 IStrategy 类.
set_strategy: async ({ strategy, reload = true }) => {
if (!strategy) throw new Error('strategy 必填, 例: {"strategy":"MyStrat"}');
const env = coinclawEnv();
if (!env) throw new Error('set_strategy 仅在 CoinClaw 容器内可用');
// 验证策略文件存在.
const stratFile = `${env.strategyPath}/${strategy}.py`;
if (!existsSync(stratFile)) {
throw new Error(`策略文件不存在: ${stratFile}. 先用 ft-deploy.mjs create_strategy 或写到 ${env.strategyPath}/`);
}
const cfg = readConfig();
const before = cfg.strategy;
cfg.strategy = strategy;
writeConfigAtomic(cfg);
let restart = null;
if (reload) restart = await restartDaemon();
return {
from: before, to: strategy, file: stratFile, restart,
note: '策略生效需要 daemon 重启完成 (10-30s), 之后 dashboard 会刷出新策略名',
};
},
// 切交易对白名单. pair_whitelist 改了之后调 /reload_config 即可,
// 不需要重启 daemon. freqtrade 会在下一根 candle close 时应用.
set_pairs: async ({ pairs, reload = true }) => {
if (!Array.isArray(pairs) || pairs.length === 0) {
throw new Error('pairs 必填且非空, 例: {"pairs":["BTC/USDT:USDT","ETH/USDT:USDT"]}');
}
const cfg = readConfig();
if (!cfg.exchange) cfg.exchange = {};
const before = cfg.exchange.pair_whitelist;
cfg.exchange.pair_whitelist = pairs;
writeConfigAtomic(cfg);
let reloaded = null;
if (reload) {
try { reloaded = await ftPost('reload_config'); } catch (e) { reloaded = { error: e.message }; }
}
return { from: before, to: pairs, reloaded };
},
// 切实盘 / 模拟. dry_run 改了必须 daemon 重启 — exchange 客户端在
// freqtrade 启动时根据 dry_run 选 ccxt vs ccxt sandbox, 运行中切不掉.
// 实盘要求交易所 API key 已经写到 config.exchange.key/secret (entrypoint
// 启动时从 .env 自动 patch 进去).
set_dry_run: async ({ dry_run, restart = true }) => {
if (typeof dry_run !== 'boolean') {
throw new Error('dry_run 必填且为 boolean, 例: {"dry_run":false}');
}
const cfg = readConfig();
const before = cfg.dry_run;
cfg.dry_run = dry_run;
writeConfigAtomic(cfg);
let restartResult = null;
if (restart) restartResult = await restartDaemon();
return {
from: before, to: dry_run, restart: restartResult,
warning: dry_run ? null : '⚠️ 已切到实盘 — 真实交易, 真实亏损. 确认 .env 里的交易所 key 是对的, 余额可控.',
};
},
// ── 状态 / 持仓 / 交易历史 (REST GET) ───────────────────────
balance: () => ftGet('balance'),
// /status 返回 open trades 数组, 命名 trades_open 比 status 直观 — agent
// 看到 "trades_open" 不会误以为是 daemon 状态.
trades_open: () => ftGet('status'),
trades_count: () => ftGet('count'),
trade_by_id: ({ trade_id }) => ftGet(`trade/${trade_id}`),
trades_history: ({ limit, offset } = {}) => ftGet('trades', { limit, offset }),
// 仓位 force-enter / force-exit, 注意 freqtrade REST 这两个端点是
// 'forcebuy' / 'forcesell' (历史名) 不是 force_enter/force_exit.
force_enter: (p) => ftPost('forcebuy', p),
force_exit: (p) => ftPost('forcesell', p),
cancel_order: ({ trade_id }) => ftDelete(`trades/${trade_id}/open-order`),
delete_trade: ({ trade_id }) => ftDelete(`trades/${trade_id}`),
// ── 盈亏 / 绩效 ────────────────────────────────────────────
// /profit 是回答 "现在赚多少 / 盈亏多少" 类问题的权威接口:
// - profit_closed_coin: 已平仓累计盈亏 (USDT) — dashboard 顶栏的累计盈亏 = 这个
// - profit_all_coin: 已平仓 + 浮动 (含未平仓) 总盈亏 (USDT)
// - 浮动盈亏 = profit_all_coin - profit_closed_coin
// - closed_trade_count: 已平仓交易数
// 反例: 只调 /status 拿 open trades 浮动盈亏会漏掉已平仓部分,
// 跟 dashboard 数字不一致.
profit: () => ftGet('profit'),
profit_per_pair: () => ftGet('performance'),
daily: ({ count } = {}) => ftGet('daily', { timescale: count }),
weekly: ({ count } = {}) => ftGet('weekly', { timescale: count }),
monthly: ({ count } = {}) => ftGet('monthly', { timescale: count }),
stats: () => ftGet('stats'),
// ── 日志 (受 freqtrade api 自带 limit 限制) ────────────────
logs: ({ limit } = {}) => ftGet('logs', { limit }),
});
# FundingRateStrategy - Exploit extreme funding rates for mean reversion
# Powered by AiCoin's cross-exchange weighted funding rate data
#
# How it works:
# - Extreme positive funding -> market over-leveraged long -> expect pullback -> short
# - Extreme negative funding -> market over-leveraged short -> expect bounce -> long
# - Uses Bollinger Bands for timing entries at price extremes
# - AiCoin advantage: volume-weighted funding rates across ALL exchanges,
# not just a single exchange's rate (more accurate market sentiment)
#
# AiCoin tier required: Basic ($29/mo) for funding_rate
# Backtest: works with Bollinger Bands + RSI only
# Live: funding rate data adds significant edge
#
from freqtrade.strategy import IStrategy, IntParameter, DecimalParameter
from pandas import DataFrame
import logging
logger = logging.getLogger(__name__)
class FundingRateStrategy(IStrategy):
INTERFACE_VERSION = 3
timeframe = '1h'
can_short = True
# ROI table (optimized via hyperopt)
minimal_roi = {"0": 0.374, "167": 0.11, "554": 0.085, "1841": 0}
stoploss = -0.213
trailing_stop = True
trailing_stop_positive = 0.142
trailing_stop_positive_offset = 0.23
trailing_only_offset_is_reached = False
# Hyperopt parameters (defaults from hyperopt optimization)
bb_period = IntParameter(15, 30, default=23, space='buy')
bb_std = DecimalParameter(1.5, 3.0, default=2.215, space='buy')
rsi_oversold = IntParameter(20, 40, default=20, space='buy')
rsi_overbought = IntParameter(60, 80, default=68, space='sell')
funding_threshold = DecimalParameter(0.01, 0.10, default=0.013, space='buy')
# AiCoin live data
_ac_funding_rate = 0.0 # Current funding rate (%)
_ac_funding_trend = 0.0 # Funding rate momentum
_ac_last_update = 0.0
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# ── Bollinger Bands ──
period = self.bb_period.value
dataframe['bb_mid'] = dataframe['close'].rolling(window=period).mean()
rolling_std = dataframe['close'].rolling(window=period).std()
dataframe['bb_upper'] = dataframe['bb_mid'] + self.bb_std.value * rolling_std
dataframe['bb_lower'] = dataframe['bb_mid'] - self.bb_std.value * rolling_std
# Bollinger Band width (volatility measure)
dataframe['bb_width'] = (dataframe['bb_upper'] - dataframe['bb_lower']) / dataframe['bb_mid']
# ── RSI ──
delta = dataframe['close'].diff()
gain = delta.clip(lower=0).rolling(window=14).mean()
loss = (-delta.clip(upper=0)).rolling(window=14).mean()
rs = gain / loss
dataframe['rsi'] = 100 - (100 / (1 + rs))
# ── Volume ──
dataframe['vol_sma'] = dataframe['volume'].rolling(window=20).mean()
# ── AiCoin funding rate (live only) ──
dataframe['funding_rate'] = 0.0
dataframe['funding_extreme'] = 0 # -1=very negative, 0=neutral, +1=very positive
if self.dp and self.dp.runmode.value in ('live', 'dry_run'):
import time
now = time.time()
if now - self._ac_last_update > 300:
self._update_funding(metadata)
self._ac_last_update = now
dataframe.iloc[-1, dataframe.columns.get_loc('funding_rate')] = self._ac_funding_rate
threshold = self.funding_threshold.value
if self._ac_funding_rate > threshold:
dataframe.iloc[-1, dataframe.columns.get_loc('funding_extreme')] = 1
elif self._ac_funding_rate < -threshold:
dataframe.iloc[-1, dataframe.columns.get_loc('funding_extreme')] = -1
return dataframe
def _update_funding(self, metadata: dict):
"""Fetch the latest funding rate from AiCoin (live/dry-run only)."""
try:
import sys, os
_sd = os.path.dirname(os.path.abspath(__file__))
if _sd not in sys.path:
sys.path.insert(0, _sd)
from aicoin_data import AiCoinData
ac = AiCoinData(cache_ttl=300)
pair = metadata.get('pair', 'BTC/USDT:USDT')
exchange = self.config.get('exchange', {}).get('name', 'binance')
try:
self._ac_funding_rate = ac.funding_rate_pct(pair, exchange)
logger.info(f"AiCoin funding rate for {pair}: {self._ac_funding_rate:.4f}%")
except Exception as e:
logger.debug(f"AiCoin funding_rate unavailable: {e}")
except ImportError:
logger.warning("aicoin_data module not found. Run ft-deploy.mjs to install.")
except Exception as e:
logger.warning(f"AiCoin data error: {e}")
def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# Long: price at BB lower + RSI oversold + volume confirmation
dataframe.loc[
(dataframe['close'] < dataframe['bb_lower']) &
(dataframe['rsi'] < self.rsi_oversold.value) &
(dataframe['volume'] > dataframe['vol_sma'] * 0.5) &
# Funding boost: negative funding = shorts paying, expect squeeze
(dataframe['funding_extreme'] <= 0),
'enter_long'] = 1
# Short: price at BB upper + RSI overbought + volume confirmation
dataframe.loc[
(dataframe['close'] > dataframe['bb_upper']) &
(dataframe['rsi'] > self.rsi_overbought.value) &
(dataframe['volume'] > dataframe['vol_sma'] * 0.5) &
# Funding boost: positive funding = longs paying, expect dump
(dataframe['funding_extreme'] >= 0),
'enter_short'] = 1
return dataframe
def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
# Exit long when price reaches BB mid (conservative take-profit)
dataframe.loc[
(dataframe['close'] > dataframe['bb_mid']) &
(dataframe['rsi'] > 55),
'exit_long'] = 1
# Exit short when price reaches BB mid
dataframe.loc[
(dataframe['close'] < dataframe['bb_mid']) &
(dataframe['rsi'] < 45),
'exit_short'] = 1
return dataframe
Related skills
How it compares
Use aicoin-freqtrade for Freqtrade-native AiCoin signal helpers; use aicoin-market when Node agents need raw Open Data v3 API access without strategy wrappers.
FAQ
How many exchanges does aicoin-freqtrade aggregate?
aicoin-freqtrade pulls AiCoin aggregated market data from 200+ exchanges through the AiCoin Open API v3 Python SDK. Helpers return normalized numbers for whale, long-short, funding, and liquidation signals.
Which Freqtrade helpers does aicoin-freqtrade provide?
aicoin-freqtrade provides AiCoinData helpers including whale_signal, ls_ratio_norm, funding_rate_pct, and liq_bias. Strategy code imports aicoin_data and calls these methods with pairs such as BTC/USDT:USDT on Binance.
Is Aicoin Freqtrade safe to install?
skills.sh reports 1 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.