OASIS BLOG
← 回到七日冲刺← D3 笔记D5 笔记 →
写给没碰过股票、期货、合约的 Java 程序员 · D4

D4 零基础速成笔记

今天开始动手:用 Java 写一个"模拟盘网格机器人"。它接上真实的公开行情,在本地假装成交,按网格规则自动挂单。每个新词都先讲清楚,再给能照着敲的代码。带下划虚线的词,点一下就能看解释。

怎么读按顺序读。第 4 到第 9 节每节对应一段代码,边读边在 IDE 里敲。算上写代码,大约 3–4 小时。
每个概念都分四层大白话 → 生活例子 → 算一遍 → Java 类比。讲解看懂了,代码就不难。
读完以后回到七日冲刺的 D4,对照整体结构图和项目骨架检查你的代码,再做 nextOrderOnFill 编程题。
1

交易机器人是什么

本节新词交易机器人 · 盯盘 · 策略

交易机器人就是一个替人盯着价格、按事先写好的规则自动下单的程序。你们组做的网格、马丁、跟单,都是这种程序。

盯盘 和 策略

大白话
盯盘:一直看着价格变化,等机会。策略:写清楚"什么情况下买、什么情况下卖"的规则。D1 学的网格就是一种策略。
Java 类比
策略就是一个接口,网格是它的一个实现:interface Strategy → class GridStrategy implements Strategy。第 7 节就会写这个 (网格策略类)。

交易机器人 trading bot

大白话
人不可能 24 小时盯盘。把策略写成代码,让程序替你盯着、替你下单。
生活例子
空调的恒温模式:高于 26 度就制冷,低于 24 度就停。你只设规则,空调自己盯着温度干活。
算一遍
D1 的网格:10 格,每格 0.01 个 BTC。价格一天来回晃 30 次,人得手动下 30 张单,还一次都不能错过。机器人一张都不会漏。
Java 类比
一个一直运行的进程,里面是"收到消息 → 判断规则 → 调接口"的循环,跟你写过的 MQ 消费者很像。
2

模拟盘:真行情、假钱

本节新词模拟盘 · 实盘 · 公开行情

模拟盘 paper trading

大白话
价格用真的,钱用假的,成交由你的程序在本地"假装"出来。
生活例子
驾校的练车场:车是真车,但撞了桩不用赔钱。英文 paper 是"纸上"的意思,就是纸上谈兵。
算一遍
真实价格从 60,010 跌到 58,990。你程序里挂在 59,000 的买单就"算成交",假账户里减 590 U、加 0.01 个 BTC。全程没有一分真钱动过。
Java 类比
测试里的 mock:把"交易所下单接口"换成一个本地的假实现(今天叫它 ,意思是"模拟撮合器",第 5 节写),其他代码一行都不用改。

实盘 和 公开行情

实盘
用真钱在真交易所下单。今天不碰。
公开行情
交易所公开推送给所有人的价格和成交数据。不需要账户,不需要登录,谁都能看。
为什么先做模拟盘
① 不需要账户,也不花钱;② 逻辑写错了也不会亏钱;③ 进组以后,测试环境里大概率也有类似的"假撮合",思路是一样的。
底线
只用公开数据,不注册账户,不交易。
3

机器人的四个零件

本节新词行情 · 成交回报 · 事件 · 事件驱动

不管多复杂的交易机器人,都是四个零件串起来:行情进来 → 策略判断 → 下单 → 成交回报回来、更新状态,然后再等下一条行情。

行情 和 成交回报

行情
价格变化的消息流。交易所每成交一笔,就推一条"刚才有人以 60,010 成交了 0.002 个",60,010 就是这一笔的成交价。
成交回报
交易所告诉你"你的单子成交了"的消息。行情是所有人的成交,成交回报只和你自己有关。

事件 和 事件驱动

大白话
事件:一件已经发生的事,比如"价格变成 59,990"、"59,000 的买单成交了"。事件驱动:程序不主动去问,而是来一件事处理一件事。
生活例子
餐厅:客人点单(行情)→ 服务员判断要做什么(策略)→ 把单子交给后厨(下单)→ 菜好了叫号(成交回报)→ 服务员更新这桌的状态。
Java 类比
生产者-消费者。行情线程是生产者,把事件放进队列;机器人线程是消费者,一个个取出来处理。
 +-----------+  trade price  +------------+  new order  +-------------+
 | 1 Market  | ------------> | 2 Strategy | ----------> | 3 OrderGate |
 +-----------+               +-----^------+             +------+------+
                                   |                           |
                                   |     +--------------+      |
                                   +---- | 4 Fill/State | <----+
                                         +--------------+

1 行情输入 · 2 策略计算 · 3 下单(今天交给本地的模拟撮合) · 4 成交回报 / 更新网格状态。图里的 OrderGate 是下单口,一张挂单在代码里叫 Order;Fill/State 是成交回报和网格状态,一条成交回报在代码里叫 Fill。这两个类型第 5 节写。

一步一步看事件走过四个零件网格 58,000–62,000 · 每格 0.01 个
① 行情输入收到一笔市场成交价
② 策略计算按网格规则决定挂什么单
③ 下单把单子交给模拟撮合
④ 成交回报更新每一格的状态

滚到这里会自动一步步播放,像一段录屏:上面四个框里亮起来的是当前走到哪个零件,下面每一格的状态跟着变,中间那句话说明这一步发生了什么。点任何按钮就停下,可以自己用上一步 / 下一步来回看。

4

第 1 步:连上公开行情

本节新词WebSocket · 推送 · 成交流 · JSON · 字段

WebSocket 和 推送

大白话
一种一直连着的网络连接。服务器有新消息,就主动发给你,这叫推送。
生活例子
普通的 HTTP 像打电话问"现在多少钱",问一次答一次。WebSocket 像关注公众号,有新文章自动推给你。
算一遍
BTC 交易活跃时,每秒有几十到上百笔成交。用 HTTP 每秒问 100 次,又慢又会被交易所限制访问频率;WebSocket 连一次,消息自己源源不断地来。
Java 类比
JDK 11 起自带 java.net.http.WebSocket。用法就是注册一个监听器(Listener),消息来了回调你的方法。

成交流、JSON 和 字段

成交流
交易所每成交一笔就推一条消息,这串消息叫成交流(trade stream)。
JSON / 字段
消息是 JSON 格式,你天天在用。字段就是 JSON 里的一个 key。
字段名各家不同
下面用 Binance 的成交流举例: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 讲过为什么)。
会被断开
Binance 的文档写的是单个连接最长 24 小时,到点会被服务器断开(其他家也有类似限制,以文档为准)。真实系统要写自动重连,D7 再做,今天先不管。
连不上怎么办
多半是网络原因。换一家你能连上的交易所的公开成交流;或者先用第 9 节的"假行情"把逻辑跑通,行情接入最后再换。
5

第 2 步:本地模拟撮合

本节新词撮合 · 模拟撮合 · 碰到(触发)

我们没有真交易所,就在本地"假装撮合":看市场的真实成交价,有没有碰到你那些还挂着的单的价格。

撮合 和 模拟撮合

撮合
交易所把价格对得上的买单和卖单配成一笔成交(D1 第 2 节)。
模拟撮合的规则
只有两句话:
买单:市场成交价 ≤ 你的买价 → 算你成交。
卖单:市场成交价 ≥ 你的卖价 → 算你成交。
满足条件,就叫这张挂单被"碰到"了。
生活例子
你在闲鱼发了个求购:"2000 以下就买"。只要看到有人以 1,990 卖掉了同款,说明市场上有人愿意卖这么便宜,你的求购本来也能成交。
算一遍
你挂 59,000 买。市场成交 59,100 → 59,100 > 59,000,没碰到。市场成交 58,990 → 58,990 ≤ 59,000,碰到了,按你挂的 59,000 算成交。
Java 类比
一个 Map 存着所有挂单。每来一个价格就遍历一遍,符合条件的移出去,生成一个"成交"对象。

今天简化掉了什么

三处简化
① 一碰到就全部成交;② 按你的挂单价成交;③ 不考虑排队。真实交易所里同价的单要排队(D1 第 2 节的"时间优先"),可能只成交一部分(叫部分成交,第 7 节细讲)。学策略逻辑,这样就够了。
输入一串市场成交价,看哪张挂单被碰到这里只演示撮合,不挂新单

怎么看:用默认这串价格点"下一笔",第 3 笔 58,990 碰到 59,000 买单,第 5 笔 61,000 碰到 61,000 卖单(等于也算),第 6 笔 57,500 碰到 58,000 买单,62,000 卖单一直没被碰到。注意 59,800、59,100 这种没有跌破买价的价格,什么都不会发生。

常见误区 · 模拟盘赚钱,实盘就一定赚钱本页的模拟撮合"一碰到就全部成交、不排队",结果偏乐观。真实情况是:价格刚碰到你的挂单价时,你前面可能还排着别人的单,价格一反弹你就没成交。更保守的做法是规定价格要穿过挂单价(比如比买价再低一个 tickSize)才算成交。D9 讲回测时还会遇到同一个问题。

写代码之前:包、import 和 record

包和文件
为了少折腾,今天所有类先放在同一个包里(比如 gridbot),每个 public 类型一个同名 .java 文件。第 9 节的目录拆分,等跑通以后再做。
import
后面的代码为了短,省略了 import。IDE 会提示你补上,基本就是 java.math.*、java.util.*、java.util.concurrent.*、java.util.function.* 这几个包里的类。
record
JDK 16 起的新语法。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 连小数位数也比。

6

第 3 步:格子和网格状态

本节新词格子价位 · 网格状态 · 备货

格子价位 和 网格状态

大白话
格子价位:网格里每一格的价格,D1 的等差、等比就是算它的。网格状态:每一格现在是什么情况,只有三种:挂着买单、挂着卖单、空着。
算一遍
区间 58,000–62,000(下沿 58,000、上沿 62,000),分 4 格(等差)→ 5 个价位:58,000、59,000、60,000、61,000、62,000。当前价 60,020:离它最近的 60,000 空着,下面两格挂买单,上面两格挂卖单。
编号
后面说"第几格",都和数组下标一样从 0 开始:第 0 格是 58,000,第 1 格是 59,000……第 4 格是 62,000。
Java 类比
一个 List<BigDecimal> 存价位,一个同样长度的枚举数组 Cell[] 存状态,下标就是"第几格"。

备货

大白话
上面的卖单要卖币,手里得先有币。所以机器人启动时,要先按当前价买好这些币,这一步叫备货(D1 第 6 节讲过)。
算一遍
上面有 2 张卖单,每张 0.01 个 → 启动时先买 0.02 个,约 1,200 U。下面 2 张买单,要预留 58,000 × 0.01 + 59,000 × 0.01 = 1,170 U。
初始化一个网格改参数,看每一格的初始状态
买单张数–
卖单张数–
启动时要备货–
买单要预留–

怎么玩:把"当前价"改成 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;
        }
    }
}
7

第 4 步:策略核心,成交后挂反向单

本节新词反向单 · 部分成交 · clientOid(订单编号)

网格策略的核心只有一条规则:某一格的买单成交了,就在上一格挂卖单;某一格的卖单成交了,就在下一格挂买单。

反向单

大白话
买的反向是卖,卖的反向是买。低一格买进的币,要在高一格卖出去,这张卖单就是反向单。
算一遍
第 1 格(59,000)买单成交 → 在第 2 格(60,000)挂卖单。第 2 格卖单成交 → 在第 1 格重新挂买单。最上面一格的买单成交了,上面没有格子了,就不挂。

部分成交

大白话
挂了 0.01 个,只成交了 0.004 个,剩下的还在排队。
生活例子
你要买 10 斤白菜,这家只剩 4 斤,先买下这 4 斤,剩下 6 斤等下一个人来卖。
今天的规则
部分成交先不动,等全部成交了再挂反向单。这是最简单的做法;也有团队按成交了多少就先挂多少的反向单,进组后留意你们用的是哪种。

clientOid(订单编号)

大白话
我们自己给每张单子起的编号,下单时一起发给交易所,之后可以用它来查询和撤单。
今天怎么起
先用 G1-2-S-1 这种格式:网格 G1、第 2 格、卖单(S)、这一格第 1 次挂单。为什么要这么起,D5 专门讲。
Java 类比
像你自己生成的业务订单号,而不是等数据库返回自增 id。
来一个成交回报,策略会怎么做网格 58,000–62,000,共 5 格

怎么玩:默认是"第 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));
    }
}
Tips · clientOid 要能从状态"算"出来,不要用随机数G1-3-B-7 = 网格 G1、第 3 格、买单、这一格第 7 次挂单。程序重启后,不用查数据库也能把每张单该叫什么重新算出来,再拿去交易所查。用 UUID 随机生成的话,重启后就对不上号了。这是 D5 对账的前提。
8

为什么只用一个线程改状态

本节新词乱序 · 串行 · 事件循环

同一个网格的所有事件,必须一个接一个、按发生的顺序处理。最简单的办法是只让一个线程改网格状态,其他线程只负责把事件放进队列。

乱序 和 串行

大白话
乱序:先发生的事被后处理。串行:排好队,一个处理完再处理下一个。
生活例子
两个服务员同时改同一桌的点单:一个在划掉"已上的菜",一个在加"客人新点的菜",两人各改各的,最后单子上的内容谁也说不清。只让一个服务员改这张单,就不会乱。
算一遍
价格快速下跌,几乎同时产生两个事件:E1"59,000 买单成交"、E2"58,000 买单成交"。按顺序处理,状态是对的;两个线程同时处理,如果 E2 先改完、E1 后改,E1 会把 E2 刚设好的 59,000 卖单状态覆盖成"空"。下面的演示一步步给你看。
单线程 vs 两个线程下方小字是交易所上实际挂着的单

事件循环 event loop

大白话
一个线程在一个死循环里:从队列取一个事件 → 处理完 → 再取下一个。只有它能改状态。
Java 类比
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();                     // 被要求停止
        }
    }
}
Tips · 只让一个线程改网格状态行情线程、下单回调线程都只做一件事:把事件放进队列。真正改网格状态的,只有事件循环那一个线程。以后排查"偶尔多挂一张单""某一格状态不对"这类 bug,第一件事就是查:有没有别的线程绕过队列、直接改了状态。
9

串起来: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
10

今天的完成标准和常见坑

  • 运行 PublicWsClient.main,能看到一行行成交价在刷新(或者用假行情代替)
  • 运行 Main,控制台能看到"成交 …",紧接着是"挂新单 …"
  • 买单成交后,新挂的卖单在上一格;卖单成交后,新挂的买单在下一格
  • 放着跑 10 分钟,任何一格都不会同时挂两张单

常见坑

用 double
价格和数量全程用 BigDecimal,比较用 compareTo,别用 equals。
在回调里改状态
WebSocket 的回调线程不是你的机器人线程。回调里只做 loop.submit(...),不要直接改网格状态(第 8 节)。
区间设太宽
区间 ±10%、格数 8,每格差 2.5%,可能一小时都不成交一笔。练习时区间设窄一点。
主线程退出
WebSocket 是异步的,main 方法结束进程就退出了,记得 await()。
11

词典和自测

今天用到的词,包括前几天学过的,这里都能查到。

自测 7 题

读完以后

回到 七日冲刺 D4:先看"整体结构"那张图,对照第 3 节的四个零件,确认每个框你都知道是哪个类;再对照"项目骨架"检查你的目录;最后做 nextOrderOnFill 编程题,它就是第 7 节那段 Java 代码的 JavaScript 版本。