这章回答一个具体问题:当 Python 先读价格和现金,再决定是否调用 buy 时,PLM、Prefix 和 Host 审批分别改变了什么?
这是一个全本地、内存中的模拟。默认局面是:ACME 的冻结价格为 100,初始现金为 2000,Python 的限价为 110,数量为 10。程序最后交付一个 JSON 报告,其中包含 Python 的 result、各工具调用次数、买入尝试、模拟订单和剩余现金。它不连接经纪商,不发送外部订单,也不做绩效实验。
先沿一笔具体决策走完
交易源文件先调用 quote 和 cash:
examples/trading.py · L1–L14 · 打开完整源码
price = quote(symbol=inputs["symbol"])
cash_balance = cash()
if price <= inputs["limit"]:
cost = price * inputs["quantity"]
if cash_balance >= cost:
try:
order = buy(symbol=inputs["symbol"], quantity=inputs["quantity"])
result = {"decision": "bought", "price": price, "cash_snapshot": cash_balance, "order": order}
except RuntimeError:
result = {"decision": "blocked", "price": price, "cash_snapshot": cash_balance, "reason": "Host refused the simulated order"}
else:
result = {"decision": "hold", "price": price, "cash_snapshot": cash_balance, "reason": "insufficient cash"}
else:
result = {"decision": "hold", "price": price, "cash_snapshot": cash_balance, "reason": "price above limit"}
默认情况下,Python 得到 price=100 和 cash_balance=2000。价格不高于 limit=110,所以进入第一层分支;成本是 100 × 10 = 1000,现金快照足够,于是进入 try 调用 buy。
如果 Host 允许模拟订单,buy 返回一个订单对象,Python 交付 decision: "bought",并把此前的现金快照 2000 放进结果。如果 Host 拒绝,buy 抛出 RuntimeError,Python 捕获它,交付 decision: "blocked";价格和现金快照仍然保留。两种结果都来自同一段控制流。
其他局面在进入 buy 前就结束:
limit=90:100 高于限价,交付hold,没有买入尝试。cash=500:价格通过,但 500 小于 1000,交付hold,没有买入尝试。- Host 没有
-allow-order:即使 inputs 中有approved: true,也只能得到blocked。Python 不读取这个字段;是否批准由 Host 配置独立决定。 - 数量为负:Python 的现金比较可能放行,但 Host 在真正的
buy边界拒绝它。
因此要分开三个状态:Python 的 cash_balance 是读取时的快照,market 的 remainingCash 是提交成功后才减少的 Host 状态,allowOrder 是 Host 授权门。模拟订单成功后,默认剩余现金为 1000;快照字段仍可显示 2000。
从 CLI 入口到执行结果
main 只负责声明命令行参数并把配置交给 run。参数分为四组:Guest/source,执行模式与内存模式,模拟市场局面,以及 Host 审批、读延迟和超时。
cmd/trade-demo/main.go · L1–L45 · 打开完整源码
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"sort"
"strings"
"time"
spine "pysolate-spine"
)
type config struct {
wasm, source, mode, memory, symbol, inputs string
price, cash, limit, quantity int
allowOrder, showTransformed bool
readDelay, timeLimit time.Duration
}
func main() {
var c config
flag.StringVar(&c.wasm, "wasm", "dist/spine.wasm", "existing Guest artifact")
flag.StringVar(&c.source, "source", "examples/trading.py", "Python trading example")
flag.StringVar(&c.mode, "mode", "plm", "normal, plm, or prefix")
flag.StringVar(&c.memory, "memory", "fresh", "fresh, copy, or cow (Linux)")
flag.StringVar(&c.symbol, "symbol", "ACME", "fictional symbol used by the local market")
flag.IntVar(&c.price, "price", 100, "frozen simulated price, in whole demo units")
flag.IntVar(&c.cash, "cash", 2000, "initial simulated cash")
flag.IntVar(&c.limit, "limit", 110, "Python buy-price threshold")
flag.IntVar(&c.quantity, "quantity", 10, "Python requested quantity")
flag.StringVar(&c.inputs, "inputs", "", "JSON overrides for Python inputs; never grants Host approval")
flag.BoolVar(&c.allowOrder, "allow-order", false, "allow in-memory simulated orders; no broker is contacted")
flag.BoolVar(&c.showTransformed, "show-transformed", false, "print actual Guest-transformed source")
flag.DurationVar(&c.readDelay, "read-delay", 0, "optional simulated latency of each snapshot read, e.g. 300ms")
flag.DurationVar(&c.timeLimit, "timeout", 30*time.Second, "whole demo timeout")
flag.Parse()
if err := run(c, os.Stdout, os.Stderr); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
run 先拒绝未知模式、未知内存方式和非法市场数值,再建立给 Python 的默认 inputs。-inputs 可以覆盖这些 Python 输入,但代码明确没有把它当成 Host 授权。接着读取既有 Wasm artifact 和交易源文件,创建带时间序列的 trace 与 market。
cmd/trade-demo/main.go · L46–L98 · 打开完整源码
func run(c config, stdout, stderr io.Writer) error {
if c.mode != "normal" && c.mode != "plm" && c.mode != "prefix" {
return fmt.Errorf("unknown mode %q", c.mode)
}
if c.memory != "fresh" && c.memory != "copy" && c.memory != "cow" {
return fmt.Errorf("unknown memory mode %q", c.memory)
}
if c.price <= 0 || c.cash < 0 || c.quantity <= 0 || c.limit < 0 || c.readDelay < 0 || c.timeLimit <= 0 {
return fmt.Errorf("invalid simulated market settings")
}
inputs := map[string]any{"symbol": c.symbol, "limit": c.limit, "quantity": c.quantity}
if c.inputs != "" {
if err := json.Unmarshal([]byte(c.inputs), &inputs); err != nil {
return err
}
if inputs == nil {
return fmt.Errorf("inputs must be a JSON object")
}
}
wasm, err := os.ReadFile(c.wasm)
if err != nil {
return err
}
source, err := os.ReadFile(c.source)
if err != nil {
return err
}
t := newTrace(stderr)
m := newMarket(c.symbol, c.price, c.cash, c.allowOrder, c.readDelay, t)
t.log("demo.config", "", map[string]any{"simulation": true, "mode": c.mode, "memory": c.memory, "price": c.price, "cash": c.cash, "allow_order": c.allowOrder, "read_delay": c.readDelay.String()})
ctx, cancel := context.WithTimeout(context.Background(), c.timeLimit)
defer cancel()
create := spine.New
if c.memory == "copy" {
create = spine.NewPrepared
} else if c.memory == "cow" {
create = spine.NewPreparedCOW
}
manifest := m.tools()
earlyReads := make([]string, 0, len(manifest))
for name, spec := range manifest {
if spec.AllowEarlyRead {
earlyReads = append(earlyReads, name)
}
}
sort.Strings(earlyReads)
t.log("runner.create", "", nil)
runner, err := create(ctx, wasm, manifest)
if err != nil {
return err
}
defer runner.Close(context.Background())
t.log("runner.ready", "", map[string]any{"early_reads": earlyReads, "buy_early": manifest["buy"].AllowEarlyRead})
market 的 tools() 产生当前 Manifest。earlyReads 从 ToolSpec.AllowEarlyRead 收集并排序,只用于日志;buy_early 也被写进 runner.ready 日志,方便观察授权清单,而不改变 Runner 的决定。内存选项分别调用 spine.New、spine.NewPrepared 或 spine.NewPreparedCOW。
普通和 PLM 模式把完整源文本交给对应的 Run 方法。Prefix 模式则启动 producer,把源文件按换行切成 append-only 片段送入 channel;producer 监听取消,完成后由 closeSource 关闭 channel。RunPrefix 返回后,调用 stopProducer 并等待 producerDone,这才完成该 producer 的收尾。
cmd/trade-demo/main.go · L99–L162 · 打开完整源码
var output spine.Output
if c.mode == "prefix" {
chunks := make(chan string)
producerDone := make(chan struct{})
producerCtx, stopProducer := context.WithCancel(ctx)
go func() {
defer close(producerDone)
defer t.closeSource(chunks)
for i, chunk := range strings.SplitAfter(string(source), "\n") {
if chunk == "" {
continue
}
t.log("source.send", "", map[string]any{"chunk": i + 1, "text": chunk})
select {
case chunks <- chunk:
case <-producerCtx.Done():
t.log("source.cancelled", "", nil)
return
}
}
}()
output, err = runner.RunPrefix(ctx, chunks, inputs)
stopProducer()
<-producerDone
} else {
t.log("source.complete", "", map[string]any{"bytes": len(source)})
execute := runner.Run
if c.mode == "plm" {
execute = runner.RunPLM
}
output, err = execute(ctx, string(source), inputs)
}
t.log("run.finished", "", map[string]any{"transformed": output.Transformed != "", "error": errorText(err)})
if c.showTransformed {
fmt.Fprintln(stderr, "--- actual transformed source ---")
if output.Transformed == "" {
fmt.Fprintln(stderr, "(no AST rewrite)")
} else {
fmt.Fprintln(stderr, output.Transformed)
}
}
if output.Stdout != "" {
t.log("guest.stdout", "", output.Stdout)
}
if err != nil {
return err
}
report := struct {
Simulation bool `json:"simulation"`
Mode string `json:"mode"`
Result json.RawMessage `json:"result"`
Calls map[string]int `json:"tool_calls"`
Attempts int `json:"buy_attempts"`
Orders []order `json:"orders"`
Remaining int `json:"remaining_cash"`
}{true, c.mode, output.Value, m.calls, m.attempts, append([]order{}, m.orders...), m.remainingCash}
return json.NewEncoder(stdout).Encode(report)
}
func errorText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
运行结束时 trace 记录是否改写、是否出错;可选的 -show-transformed 输出真实 transformed source。成功时 stdout 先输出 Guest 的 stdout,再输出报告。报告中的 Orders 使用 market 当前订单切片的副本,避免编码期间直接暴露内部切片;Calls、Attempts 和 Remaining 则记录这次模拟留下的 Host 观察值。
trace 只观察边界,不参与决策
trace 为每条事件分配递增序号,记录 UTC 时间和相对耗时,并用 mutex 保护写入。日志写到 stderr,所以交易报告仍走 stdout;注释也明确说明 trace 不改变运行决定。
cmd/trade-demo/market.go · L1–L42 · 打开完整源码
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"sync"
"time"
spine "pysolate-spine"
)
type event struct {
Time string `json:"time"`
ElapsedMS float64 `json:"elapsed_ms"`
Sequence int `json:"seq"`
Event string `json:"event"`
Tool string `json:"tool,omitempty"`
Data any `json:"data,omitempty"`
}
type trace struct {
mu sync.Mutex
start time.Time
sequence int
writer io.Writer
}
func newTrace(w io.Writer) *trace { return &trace{start: time.Now(), writer: w} }
func (t *trace) log(kind, tool string, data any) {
t.mu.Lock()
defer t.mu.Unlock()
t.write(kind, tool, data)
}
func (t *trace) write(kind, tool string, data any) {
t.sequence++
now := time.Now()
e := event{Time: now.UTC().Format(time.RFC3339Nano), ElapsedMS: float64(now.Sub(t.start).Microseconds()) / 1000, Sequence: t.sequence, Event: kind, Tool: tool, Data: data}
// This CLI's log writer is stderr; traces never alter runtime decisions.
_ = json.NewEncoder(t.writer).Encode(e)
}
closeSource 在同一把锁下关闭 channel 并记录 source.closed。这使 Prefix 的“channel 已关闭”与日志事件保持在同一段收尾动作里;它不等于宣称任意未退出 worker 都已经释放。
market 怎样包装工具并管理订单
order 保存 ID、symbol、quantity、成交价和 cost;market 保存冻结价格、初始现金、剩余现金、授权开关、读延迟、订单、调用计数和 trace。newMarket 只建立内存状态,没有任何外部连接。
cmd/trade-demo/market.go · L43–L71 · 打开完整源码
func (t *trace) closeSource(ch chan string) {
t.mu.Lock()
defer t.mu.Unlock()
close(ch)
t.write("source.closed", "", nil)
}
type order struct {
ID int `json:"id"`
Symbol string `json:"symbol"`
Quantity int `json:"quantity"`
Price int `json:"price"`
Cost int `json:"cost"`
}
type market struct {
mu sync.Mutex
symbol string
price, initialCash, remainingCash int
allowOrder bool
delay time.Duration
orders []order
attempts int
calls map[string]int
trace *trace
}
func newMarket(symbol string, price, cash int, allow bool, delay time.Duration, t *trace) *market {
return &market{symbol: symbol, price: price, initialCash: cash, remainingCash: cash, allowOrder: allow, delay: delay, calls: map[string]int{}, trace: t}
}
wrap 在真实工具函数前后记录调用次数、tool.start、tool.end 或 tool.error。它不吞掉错误,也不把日志结果变成新的返回值。waitRead 用 timer 模拟每次快照读取的延迟,同时监听 context;延迟为 0 时直接返回 context 状态。
cmd/trade-demo/market.go · L72–L99 · 打开完整源码
func (m *market) wrap(name string, fn spine.Tool) spine.Tool {
return func(ctx context.Context, args json.RawMessage) (any, error) {
m.mu.Lock()
m.calls[name]++
m.mu.Unlock()
m.trace.log("tool.start", name, args)
value, err := fn(ctx, args)
if err != nil {
m.trace.log("tool.error", name, map[string]any{"error": err.Error()})
} else {
m.trace.log("tool.end", name, map[string]any{"result": value})
}
return value, err
}
}
func (m *market) waitRead(ctx context.Context) error {
if m.delay == 0 {
return ctx.Err()
}
timer := time.NewTimer(m.delay)
defer timer.Stop()
select {
case <-timer.C:
return ctx.Err()
case <-ctx.Done():
return ctx.Err()
}
}
quote 解析 symbol,拒绝未知模拟标的,等待可取消的读取后返回冻结价格。cash 同样等待读取,但返回 initialCash:这是允许提前读取的冻结快照,即使稍后有成功买入也不回写这个值。
buy 是唯一会改变 market 状态的工具。它先在锁内增加尝试次数、检查 context,再检查 allowOrder。未获 Host 许可时立即写入 order.denied,明确 account_changed: false,然后返回错误。通过授权后才解析 symbol 与 quantity,拒绝错误标的和非正数量。
cmd/trade-demo/market.go · L100–L158 · 打开完整源码
func (m *market) tools() spine.Manifest {
return spine.Manifest{
"quote": {Call: m.wrap("quote", func(ctx context.Context, args json.RawMessage) (any, error) {
var a struct {
Symbol string `json:"symbol"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, err
}
if a.Symbol != m.symbol {
return nil, fmt.Errorf("unknown simulated symbol: %s", a.Symbol)
}
if err := m.waitRead(ctx); err != nil {
return nil, err
}
return m.price, nil
}), AllowEarlyRead: true},
"cash": {Call: m.wrap("cash", func(ctx context.Context, _ json.RawMessage) (any, error) {
if err := m.waitRead(ctx); err != nil {
return nil, err
}
// A frozen snapshot is eligible for early reads even if a later buy changes the account.
return m.initialCash, nil
}), AllowEarlyRead: true},
"buy": {Call: m.wrap("buy", func(ctx context.Context, args json.RawMessage) (any, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.attempts++
if err := ctx.Err(); err != nil {
return nil, err
}
if !m.allowOrder {
m.trace.log("order.denied", "buy", map[string]any{"reason": "Host approval missing", "account_changed": false})
return nil, fmt.Errorf("simulated order requires Host -allow-order")
}
var a struct {
Symbol string `json:"symbol"`
Quantity int `json:"quantity"`
}
if err := json.Unmarshal(args, &a); err != nil {
return nil, err
}
if a.Symbol != m.symbol || a.Quantity <= 0 {
return nil, fmt.Errorf("invalid simulated order")
}
// Recheck the current account, independently of Python's control flow. Division avoids overflow.
if a.Quantity > m.remainingCash/m.price {
m.trace.log("order.denied", "buy", map[string]any{"reason": "insufficient current cash", "account_changed": false})
return nil, fmt.Errorf("insufficient current cash")
}
m.trace.log("order.authorized", "buy", map[string]any{"simulation": true})
o := order{ID: len(m.orders) + 1, Symbol: a.Symbol, Quantity: a.Quantity, Price: m.price, Cost: a.Quantity * m.price}
m.remainingCash -= o.Cost
m.orders = append(m.orders, o)
m.trace.log("order.committed", "buy", map[string]any{"simulation": true, "order": o, "remaining_cash": m.remainingCash})
return o, nil
})},
}
}
最后一次现金检查针对当前的 remainingCash,用 quantity > remainingCash/price 避免先乘法造成溢出。检查通过后才记录 order.authorized,创建订单、扣减现金、追加订单,并记录 order.committed。因此 Python 控制流即使判断过现金,也不能绕过 Host 的当前账户检查;失败路径不会产生订单或扣款。
三种模式怎样影响观察时机
- normal:完整源代码执行,所有工具调用在 Python 原调用处发生。
- plm:
quote和cash的AllowEarlyRead允许 PLM 提前准备快照;buy没有这个标记,不能因它会返回订单就提前提交。最终控制流仍在原位置交付值或错误。 - prefix:源码先以片段到达。只有完整输入结束后,程序才走到
buy的调用位置。
这些区别是观察时机和准备资格的区别,不是把模拟订单变成真实交易的区别。
测试怎样证明这些说法
测试复用真实 artifact,并在 normal、plm、prefix 三种模式下重复同一组 price/cash/Host 局面。每个 case 开始前重置 market 的现金、授权、尝试次数、订单和调用计数;运行返回后再检查结果和 Host 状态。
cmd/trade-demo/main_test.go · L1–L38 · 打开完整源码
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"os"
"strings"
"testing"
"time"
spine "pysolate-spine"
)
func testRunner(t *testing.T, m *market, manifest spine.Manifest) *spine.Runner {
t.Helper()
wasm, err := os.ReadFile("../../dist/spine.wasm")
if err != nil {
t.Fatal(err)
}
if manifest == nil {
manifest = m.tools()
}
r, err := spine.New(context.Background(), wasm, manifest)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := r.Close(context.Background()); err != nil {
t.Error(err)
}
})
return r
}
func inputs(limit, quantity int) map[string]any {
return map[string]any{"symbol": "ACME", "limit": limit, "quantity": quantity, "approved": true}
}
cmd/trade-demo/main_test.go · L39–L105 · 打开完整源码
func TestTradingControlFlowAndHostGate(t *testing.T) {
code, err := os.ReadFile("../../examples/trading.py")
if err != nil {
t.Fatal(err)
}
m := newMarket("ACME", 100, 2000, false, 0, newTrace(io.Discard))
r := testRunner(t, m, nil)
cases := []struct {
name string
limit, cash, quantity int
allow bool
decision string
attempts, orders, remaining int
}{
{"price skip", 90, 2000, 10, true, "hold", 0, 0, 2000},
{"cash skip", 110, 500, 10, true, "hold", 0, 0, 500},
{"host denial despite approved input", 110, 2000, 10, false, "blocked", 1, 0, 2000},
{"approved simulation", 110, 2000, 10, true, "bought", 1, 1, 1000},
{"host validates bad quantity", 110, 2000, -1, true, "blocked", 1, 0, 2000},
}
for _, mode := range []string{"normal", "plm", "prefix"} {
for _, tc := range cases {
t.Run(mode+"/"+tc.name, func(t *testing.T) {
m.mu.Lock()
m.initialCash = tc.cash
m.remainingCash = tc.cash
m.allowOrder = tc.allow
m.attempts = 0
m.orders = nil
m.calls = map[string]int{}
m.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var out spine.Output
var err error
if mode == "prefix" {
ch := make(chan string, 1)
ch <- string(code)
close(ch)
out, err = r.RunPrefix(ctx, ch, inputs(tc.limit, tc.quantity))
} else if mode == "plm" {
out, err = r.RunPLM(ctx, string(code), inputs(tc.limit, tc.quantity))
} else {
out, err = r.Run(ctx, string(code), inputs(tc.limit, tc.quantity))
}
if err != nil {
t.Fatal(err)
}
var got struct {
Decision string `json:"decision"`
}
if err = json.Unmarshal(out.Value, &got); err != nil {
t.Fatal(err)
}
if got.Decision != tc.decision || m.attempts != tc.attempts || len(m.orders) != tc.orders || m.remainingCash != tc.remaining {
t.Fatalf("result=%s attempts=%d orders=%v remaining=%d", out.Value, m.attempts, m.orders, m.remainingCash)
}
if m.calls["quote"] != 1 || m.calls["cash"] != 1 {
t.Fatalf("duplicated snapshot reads: %v", m.calls)
}
if mode != "normal" && out.Transformed == "" {
t.Fatal("expected actual AST rewrite")
}
})
}
}
}
表格断言 decision、买入尝试数、订单数和剩余现金,并要求 quote、cash 各只读一次。PLM 和 Prefix 还必须留下实际 transformed source。测试没有断言事件的绝对时间或具体 AST 文本,只锁定可观察语义。
Prefix 的同步测试把完整代码放入 channel 后启动 RunPrefix,通过两个包装工具的 started channel 等待 quote 和 cash 已开始。观察到两个启动信号后、关闭 source channel 之前,检查 market 中仍然没有 buy。随后显式关闭 source channel,等待 Run 完成,再确认只产生一个订单。这里用 channel 和完成通知同步,不用 sleep 猜测调度。
cmd/trade-demo/main_test.go · L106–L150 · 打开完整源码
func TestTradingPrefixReadsBeforeEOFWithoutBuying(t *testing.T) {
code, err := os.ReadFile("../../examples/trading.py")
if err != nil {
t.Fatal(err)
}
m := newMarket("ACME", 100, 2000, true, 0, newTrace(io.Discard))
started := make(chan string, 2)
manifest := m.tools()
for _, name := range []string{"quote", "cash"} {
name, spec := name, manifest[name]
original := spec.Call
spec.Call = func(ctx context.Context, args json.RawMessage) (any, error) {
started <- name
return original(ctx, args)
}
manifest[name] = spec
}
r := testRunner(t, m, manifest)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ch := make(chan string, 1)
ch <- string(code)
done := make(chan error, 1)
go func() { _, err := r.RunPrefix(ctx, ch, inputs(110, 10)); done <- err }()
for i := 0; i < 2; i++ {
select {
case <-started:
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
m.mu.Lock()
attempts, orders := m.attempts, len(m.orders)
m.mu.Unlock()
if attempts != 0 || orders != 0 {
t.Fatal("buy occurred before source EOF")
}
close(ch)
if err = <-done; err != nil {
t.Fatal(err)
}
if m.attempts != 1 || len(m.orders) != 1 {
t.Fatalf("expected one post-EOF order: %d %v", m.attempts, m.orders)
}
}
另一个测试直接尝试 prepare buy,要求返回 0 且没有尝试次数;随后即使 inputs 带有 approved,关闭 Host allowOrder 后的直接 buy 也必须失败且没有订单。这固定了“不能准备副作用工具”和“Python 不能自我授权”的边界。
cmd/trade-demo/main_test.go · L151–L167 · 打开完整源码
func TestTradingBuyCannotBePreparedOrSelfAuthorized(t *testing.T) {
m := newMarket("ACME", 100, 2000, true, 0, newTrace(io.Discard))
r := testRunner(t, m, nil)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
source := `import _spine, json
result = _spine.prepare(json.dumps({"tool":"buy","args":{"symbol":"ACME","quantity":1}}))`
out, err := r.RunPLM(ctx, source, nil)
if err != nil || string(out.Value) != "0" || m.attempts != 0 {
t.Fatalf("buy prepare: %s %v %d", out.Value, err, m.attempts)
}
m.allowOrder = false
_, err = r.RunPLM(ctx, `result=buy(symbol="ACME",quantity=1)`, map[string]any{"approved": true})
if err == nil || !strings.Contains(err.Error(), "Host -allow-order") || len(m.orders) != 0 {
t.Fatalf("unauthorized direct order: %v %v", err, m.orders)
}
}
trace 测试直接调用被拒绝的 buy,按顺序读取 tool.start → order.denied → tool.error,检查序号连续、elapsed 不倒退、时间可解析。它只证明真实工具边界被记录,没有把日志时间当作交易语义。
cmd/trade-demo/main_test.go · L168–L191 · 打开完整源码
func TestTraceRecordsRealToolBoundaries(t *testing.T) {
var logs bytes.Buffer
m := newMarket("ACME", 100, 2000, false, 0, newTrace(&logs))
_, err := m.tools()["buy"].Call(context.Background(), json.RawMessage(`{"symbol":"ACME","quantity":1}`))
if err == nil {
t.Fatal("expected denial")
}
dec := json.NewDecoder(&logs)
expected := []string{"tool.start", "order.denied", "tool.error"}
previous := 0.0
for i, kind := range expected {
var e event
if err := dec.Decode(&e); err != nil {
t.Fatal(err)
}
if e.Event != kind || e.Sequence != i+1 || e.ElapsedMS < previous {
t.Fatalf("event: %+v", e)
}
if _, err := time.Parse(time.RFC3339Nano, e.Time); err != nil {
t.Fatal(err)
}
previous = e.ElapsedMS
}
}
market 单元测试再把账户检查单独拉出来:第一次买 15 股后余额为 500,第二次相同请求被当前现金拒绝,订单仍只有一笔;cash 读取仍返回初始快照 2000。
cmd/trade-demo/market_test.go · L1–L27 · 打开完整源码
package main
import (
"context"
"encoding/json"
"io"
"testing"
)
func TestMarketRechecksCashAtEveryOrder(t *testing.T) {
m := newMarket("ACME", 100, 2000, true, 0, newTrace(io.Discard))
manifest := m.tools()
args := json.RawMessage(`{"symbol":"ACME","quantity":15}`)
if _, err := manifest["buy"].Call(context.Background(), args); err != nil {
t.Fatal(err)
}
if _, err := manifest["buy"].Call(context.Background(), args); err == nil {
t.Fatal("overspending accepted")
}
if len(m.orders) != 1 || m.remainingCash != 500 {
t.Fatalf("orders=%v cash=%d", m.orders, m.remainingCash)
}
snapshot, err := manifest["cash"].Call(context.Background(), json.RawMessage(`{}`))
if err != nil || snapshot != 2000 {
t.Fatalf("snapshot changed: %v %v", snapshot, err)
}
}
下一步
想看可复制的命令入口,读快捷脚本;想看普通 lookup 程序如何进入 Host,读 Host 工具边界 和 函数流程入口。读完本章应能回答:价格和现金什么时候被读取,什么条件才进入 buy,以及成功/拒绝分别会在 Host 留下什么状态。