交易机器人是什么
交易机器人就是一个替人盯着价格、按事先写好的规则自动下单的程序。你们组做的网格、马丁、跟单,都是这种程序。
盯盘 和 策略
interface Strategy → class GridStrategy implements Strategy。第 7 节就会写这个 (网格策略类)。交易机器人 trading bot
模拟盘:真行情、假钱
模拟盘 paper trading
实盘 和 公开行情
机器人的四个零件
不管多复杂的交易机器人,都是四个零件串起来:行情进来 → 策略判断 → 下单 → 成交回报回来、更新状态,然后再等下一条行情。
行情 和 成交回报
事件 和 事件驱动
+-----------+ trade price +------------+ new order +-------------+
| 1 Market | ------------> | 2 Strategy | ----------> | 3 OrderGate |
+-----------+ +-----^------+ +------+------+
| |
| +--------------+ |
+---- | 4 Fill/State | <----+
+--------------+
1 行情输入 · 2 策略计算 · 3 下单(今天交给本地的模拟撮合) · 4 成交回报 / 更新网格状态。图里的 OrderGate 是下单口,一张挂单在代码里叫 Order;Fill/State 是成交回报和网格状态,一条成交回报在代码里叫 Fill。这两个类型第 5 节写。
滚到这里会自动一步步播放,像一段录屏:上面四个框里亮起来的是当前走到哪个零件,下面每一格的状态跟着变,中间那句话说明这一步发生了什么。点任何按钮就停下,可以自己用上一步 / 下一步来回看。
第 1 步:连上公开行情
WebSocket 和 推送
java.net.http.WebSocket。用法就是注册一个监听器(Listener),消息来了回调你的方法。成交流、JSON 和 字段
p 是价格,q 是数量,这是 Binance 自己的叫法。任选一家你网络能连上的交易所都行,字段名看它的公开 API 文档。标红的两行是今天真正要用的:价格和数量。其他字段先知道是什么意思就行。
价格一律用 BigDecimal 保存(D2 讲过为什么不用 double)。下面是完整可运行的代码。类名 PublicWsClient 意思是"公开行情客户端":连上 Binance 的公开成交流,每来一笔就打印成交价。不需要任何依赖,JDK 17 直接跑。为了零依赖,这里用正则从 JSON 里抠出 p 字段;正式项目请用 Jackson 之类的 JSON 库解析。
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.CountDownLatch;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PublicWsClient {
// Binance 成交流:p = 成交价格,q = 成交数量(字段名各家不同)
private static final Pattern P = Pattern.compile("\"p\":\"([0-9.]+)\"");
private static final Pattern Q = Pattern.compile("\"q\":\"([0-9.]+)\""); // 数量今天先不用,留着备用
private static final String URL = "wss://stream.binance.com:9443/ws/btcusdt@trade";
/** 连上行情,每来一笔成交就把价格交给 onPrice */
public static void connect(Consumer<BigDecimal> onPrice) {
HttpClient.newHttpClient().newWebSocketBuilder()
.buildAsync(URI.create(URL), new WebSocket.Listener() {
private final StringBuilder buf = new StringBuilder();
@Override
public CompletionStage<?> onText(WebSocket ws, CharSequence data, boolean last) {
buf.append(data); // 一条消息可能分几段到达
if (last) {
String msg = buf.toString();
buf.setLength(0);
Matcher mp = P.matcher(msg);
if (mp.find()) onPrice.accept(new BigDecimal(mp.group(1))); // 价格用 BigDecimal
}
ws.request(1); // 处理完了,再要下一条
return null;
}
@Override
public void onError(WebSocket ws, Throwable e) {
e.printStackTrace(); // 先打印,重连 D7 再做
}
}).join();
}
public static void main(String[] args) throws InterruptedException {
connect(price -> System.out.println("成交价 " + price));
new CountDownLatch(1).await(); // 主线程别退出
}
}
这段代码要注意的三件事
"60010.50",就是为了让你直接 new BigDecimal("60010.50"),不经过 double(D2 讲过为什么)。第 2 步:本地模拟撮合
我们没有真交易所,就在本地"假装撮合":看市场的真实成交价,有没有碰到你那些还挂着的单的价格。
撮合 和 模拟撮合
买单:市场成交价 ≤ 你的买价 → 算你成交。
卖单:市场成交价 ≥ 你的卖价 → 算你成交。
满足条件,就叫这张挂单被"碰到"了。
Map 存着所有挂单。每来一个价格就遍历一遍,符合条件的移出去,生成一个"成交"对象。今天简化掉了什么
怎么看:用默认这串价格点"下一笔",第 3 笔 58,990 碰到 59,000 买单,第 5 笔 61,000 碰到 61,000 卖单(等于也算),第 6 笔 57,500 碰到 58,000 买单,62,000 卖单一直没被碰到。注意 59,800、59,100 这种没有跌破买价的价格,什么都不会发生。
写代码之前:包、import 和 record
gridbot),每个 public 类型一个同名 .java 文件。第 9 节的目录拆分,等跑通以后再做。java.math.*、java.util.*、java.util.concurrent.*、java.util.function.* 这几个包里的类。record Order(String id, ...) {} 一行就等于一个不可变的 POJO:自动生成构造器、equals、hashCode、toString,以及和字段同名的读取方法(是 o.price(),不是 o.getPrice())。如果你平时用 JDK 8,这里要装一个 JDK 17。下面四个类型:Side 表示买还是卖;Order 是一张挂单(里面的 clientOid 是我们自己给这张单起的编号,第 7 节细讲);Fill 是一条成交回报(Fill 在英文里就是"成交");PaperMatcher 是模拟撮合器(paper 就是模拟盘的"纸上")。它的方法 onTrade 里的 Trade,指市场上刚发生的一笔成交,第 8 节会把它写成一个小类型。
public enum Side { BUY, SELL }
/** 一张挂单:编号、方向、价格、数量、在第几格 */
public record Order(String clientOid, Side side, BigDecimal price, BigDecimal qty, int level) {}
/** 一条成交回报;status 是 "FILLED"(全部成交)或 "PARTIALLY_FILLED"(部分成交) */
public record Fill(String clientOid, Side side, int level, BigDecimal price, BigDecimal qty, String status) {}
public class PaperMatcher {
private final Map<String, Order> open = new LinkedHashMap<>(); // 还挂着的单
public void place(Order o) { open.put(o.clientOid(), o); }
public void cancel(String oid) { open.remove(oid); }
/** 每来一笔市场成交价,检查哪些挂单被碰到 */
public List<Fill> onTrade(BigDecimal tradePrice) {
List<Fill> fills = new ArrayList<>();
Iterator<Order> it = open.values().iterator();
while (it.hasNext()) {
Order o = it.next();
boolean hit = o.side() == Side.BUY
? tradePrice.compareTo(o.price()) <= 0 // 市场价 ≤ 我的买价
: tradePrice.compareTo(o.price()) >= 0; // 市场价 ≥ 我的卖价
if (hit) {
fills.add(new Fill(o.clientOid(), o.side(), o.level(), o.price(), o.qty(), "FILLED"));
it.remove();
}
}
return fills;
}
}
比较 BigDecimal 一定要用 compareTo。new BigDecimal("60000.0").equals(new BigDecimal("60000")) 的结果是 false,因为 equals 连小数位数也比。
第 3 步:格子和网格状态
格子价位 和 网格状态
List<BigDecimal> 存价位,一个同样长度的枚举数组 Cell[] 存状态,下标就是"第几格"。备货
怎么玩:把"当前价"改成 58,500 或 61,700,看空着的那一格跟着移动,买单和卖单的张数随之变化;把格数改成 8、方式改成等比,看价位之间的间距怎么变。下面的"备货"和"预留"就是按这张图算出来的。
下面的代码里有三个类型。GridLevels 负责算出每一格的价位;Cell 是一格的三种状态(买单、卖单、空);GridState 保存整张网格的价位和每一格的状态。参数 tick 就是 D2 讲的 tickSize(价格最小单位,比如 0.01),算出来的价位要按它取整,交易所才会接受。
public final class GridLevels {
/** 等差:每格价格差一样 */
public static List<BigDecimal> arith(BigDecimal low, BigDecimal high, int n, BigDecimal tick) {
BigDecimal step = high.subtract(low).divide(BigDecimal.valueOf(n), 10, RoundingMode.DOWN);
List<BigDecimal> out = new ArrayList<>();
for (int i = 0; i <= n; i++) {
BigDecimal p = low.add(step.multiply(BigDecimal.valueOf(i)));
out.add(p.divide(tick, 0, RoundingMode.DOWN).multiply(tick)); // 按 tickSize 取整
}
return out;
}
/** 等比:每格涨幅一样。要开 n 次方,BigDecimal 做不了,用 double 算完再按 tick 取整 */
public static List<BigDecimal> geom(BigDecimal low, BigDecimal high, int n, BigDecimal tick) {
double ratio = Math.pow(high.doubleValue() / low.doubleValue(), 1.0 / n);
List<BigDecimal> out = new ArrayList<>();
for (int i = 0; i <= n; i++) {
BigDecimal p = BigDecimal.valueOf(low.doubleValue() * Math.pow(ratio, i));
out.add(p.divide(tick, 0, RoundingMode.DOWN).multiply(tick));
}
return out;
}
}
public enum Cell { BUY, SELL, EMPTY }
public class GridState {
public final List<BigDecimal> levels; // 每一格的价格,下标 = 第几格
public final Cell[] cells; // 每一格的状态
public GridState(List<BigDecimal> levels, BigDecimal now) {
this.levels = levels;
this.cells = new Cell[levels.size()];
int nearest = 0; // 找离当前价最近的一格
for (int i = 1; i < levels.size(); i++) {
if (levels.get(i).subtract(now).abs()
.compareTo(levels.get(nearest).subtract(now).abs()) < 0) nearest = i;
}
for (int i = 0; i < cells.length; i++) {
cells[i] = i < nearest ? Cell.BUY : i > nearest ? Cell.SELL : Cell.EMPTY;
}
}
}
第 4 步:策略核心,成交后挂反向单
网格策略的核心只有一条规则:某一格的买单成交了,就在上一格挂卖单;某一格的卖单成交了,就在下一格挂买单。
反向单
部分成交
clientOid(订单编号)
G1-2-S-1 这种格式:网格 G1、第 2 格、卖单(S)、这一格第 1 次挂单。为什么要这么起,D5 专门讲。怎么玩:默认是"第 1 格买单全部成交",看第 2 格出现新挂的卖单。再试三种情况:改成"部分成交"(什么都不做);改成"第 4 格买单"(上面没格子了,不挂);改成"第 2 格卖单"(在第 1 格挂回买单)。
下面的 GridStrategy 就是网格策略类。它只有一个方法 nextOrderOnFill,名字的意思是"成交之后的下一张单":输入一条成交回报,输出要新挂的那张单,没有就返回空的 Optional。七日冲刺 D4 的编程题就是写它。
public class GridStrategy {
private final String gridId;
private final GridState state;
private final int[] cycle; // 每一格挂过几次单,用来起编号
public GridStrategy(String gridId, GridState state) {
this.gridId = gridId;
this.state = state;
this.cycle = new int[state.levels.size()];
}
/** 收到成交回报 → 算出下一张要挂的单;没有就返回 empty */
public Optional<Order> nextOrderOnFill(Fill f) {
if (!"FILLED".equals(f.status())) return Optional.empty(); // 部分成交先不动
state.cells[f.level()] = Cell.EMPTY; // 这一格成交了,变空
int next = f.side() == Side.BUY ? f.level() + 1 : f.level() - 1;
if (next < 0 || next >= state.levels.size()) return Optional.empty(); // 到边了
Side side = f.side() == Side.BUY ? Side.SELL : Side.BUY; // 反向
state.cells[next] = side == Side.BUY ? Cell.BUY : Cell.SELL;
String oid = gridId + "-" + next + "-" + side.name().charAt(0) + "-" + (++cycle[next]);
return Optional.of(new Order(oid, side, state.levels.get(next), f.qty(), next));
}
}
G1-3-B-7 = 网格 G1、第 3 格、买单、这一格第 7 次挂单。程序重启后,不用查数据库也能把每张单该叫什么重新算出来,再拿去交易所查。用 UUID 随机生成的话,重启后就对不上号了。这是 D5 对账的前提。为什么只用一个线程改状态
同一个网格的所有事件,必须一个接一个、按发生的顺序处理。最简单的办法是只让一个线程改网格状态,其他线程只负责把事件放进队列。
乱序 和 串行
事件循环 event loop
BlockingQueue + 一个线程。队列本身是线程安全的,状态只被一个线程碰,所以一把锁都不用加。进组后常见的做法是"按网格或按用户分片,每一片一个线程",和 D6 要学的 Kafka"同一个分区内有序"是一个思路。下面的 Trade 是"一笔市场成交"这个行情事件;BotLoop 就是机器人的事件循环。代码里的 e instanceof Trade t 是 JDK 16 的写法,等于"如果 e 是 Trade,就把它强转成变量 t",省掉一行强制类型转换。
public record Trade(BigDecimal price) {} // 行情事件
public class BotLoop implements Runnable {
private final BlockingQueue<Object> events = new LinkedBlockingQueue<>();
private final PaperMatcher matcher;
private final GridStrategy strategy;
public BotLoop(PaperMatcher matcher, GridStrategy strategy) {
this.matcher = matcher;
this.strategy = strategy;
}
/** 任何线程都可以调:只往队列里放,不碰状态 */
public void submit(Object event) { events.add(event); }
@Override
public void run() {
try {
while (true) {
Object e = events.take(); // 只有这一个线程在改状态
if (e instanceof Trade t) {
matcher.onTrade(t.price()).forEach(this::submit); // 碰到的挂单 → 成交回报,排回队列
} else if (e instanceof Fill f) {
System.out.println("成交 " + f);
strategy.nextOrderOnFill(f).ifPresent(o -> {
System.out.println("挂新单 " + o);
matcher.place(o);
});
}
}
} catch (InterruptedException ie) {
Thread.currentThread().interrupt(); // 被要求停止
}
}
}
串起来:main 方法和项目骨架
假行情
把前面的零件装起来。区间设在当前价上下 1% 左右、分 8 格,这样几分钟内就能看到成交。下面的 FakeFeed 就是假行情生成器(feed 意思是"喂数据的源头"):
public class Main {
public static void main(String[] args) throws Exception {
BigDecimal now = new BigDecimal("60000"); // 换成你看到的当前价
BigDecimal tick = new BigDecimal("0.01");
List<BigDecimal> levels = GridLevels.arith(
now.multiply(new BigDecimal("0.99")), now.multiply(new BigDecimal("1.01")), 8, tick);
GridState state = new GridState(levels, now);
GridStrategy strategy = new GridStrategy("G1", state);
PaperMatcher matcher = new PaperMatcher();
BigDecimal qty = new BigDecimal("0.001");
// 按初始状态挂出所有买单和卖单(模拟盘里备货直接假设已完成)
for (int i = 0; i < levels.size(); i++) {
if (state.cells[i] == Cell.EMPTY) continue;
Side side = state.cells[i] == Cell.BUY ? Side.BUY : Side.SELL;
matcher.place(new Order("G1-" + i + "-" + side.name().charAt(0) + "-0", side, levels.get(i), qty, i));
}
BotLoop loop = new BotLoop(matcher, strategy);
new Thread(loop, "bot-loop").start();
PublicWsClient.connect(price -> loop.submit(new Trade(price))); // 真行情
// FakeFeed.start(now, price -> loop.submit(new Trade(price))); // 连不上就用假行情
new CountDownLatch(1).await();
}
}
/** 假行情:每 200 毫秒随机走一步 */
public class FakeFeed {
public static void start(BigDecimal start, Consumer<BigDecimal> onPrice) {
Random r = new Random();
double[] p = { start.doubleValue() };
Executors.newSingleThreadScheduledExecutor().scheduleAtFixedRate(() -> {
p[0] *= 1 + r.nextGaussian() * 0.0008; // 每步大约 ±0.08%
onPrice.accept(BigDecimal.valueOf(p[0]).setScale(2, RoundingMode.HALF_UP));
}, 0, 200, TimeUnit.MILLISECONDS);
}
}
项目骨架(和七日冲刺 D4 那一页的一样),今天写完前四个目录。目录就是包名;如果先把所有类放在一个包里跑通了,再按这个目录拆包,拆开后记得被别的包用到的类、构造器和字段都要是 public。
grid-bot/ ├── market/ PublicWsClient.java FakeFeed.java <- 第 4、9 节 ├── sim/ PaperMatcher.java <- 第 5 节 ├── core/ GridLevels.java GridState.java │ GridStrategy.java BotLoop.java <- 第 6、7、8 节 ├── order/ Side.java Order.java Fill.java Trade.java ├── log/ EventLog.java <- 明天(D5) └── copy/ ... <- D6
今天的完成标准和常见坑
- 运行
PublicWsClient.main,能看到一行行成交价在刷新(或者用假行情代替) - 运行
Main,控制台能看到"成交 …",紧接着是"挂新单 …" - 买单成交后,新挂的卖单在上一格;卖单成交后,新挂的买单在下一格
- 放着跑 10 分钟,任何一格都不会同时挂两张单
常见坑
compareTo,别用 equals。loop.submit(...),不要直接改网格状态(第 8 节)。await()。词典和自测
今天用到的词,包括前几天学过的,这里都能查到。
自测 7 题
回到 七日冲刺 D4:先看"整体结构"那张图,对照第 3 节的四个零件,确认每个框你都知道是哪个类;再对照"项目骨架"检查你的目录;最后做 nextOrderOnFill 编程题,它就是第 7 节那段 Java 代码的 JavaScript 版本。