函数调用
把自然语言的交易请求转换为对普通有类型函数的调用,做法是把函数名和封闭集合参数映射为带置信度的 TypeSafe 问题。
当你点一杯「大杯冰燕麦拿铁,不加糖」时,咖啡师不会把你说的整句话写下来,而是在杯子上勾选四个选项。本实践手册对交易 API 做的是同一件事:输入一句话,输出一个函数名及其参数——它们是被评估过的枚举值,每个都带一个置信度。
"plot rolling correlation between nvda and spy for the past month"
rolling_correlation(symbol='NVDA', benchmark='SPY', window='1mo') confidence 0.91
"compare nvda amd and msft over the past three months"
compare_returns(symbols=['NVDA', 'AMD', 'MSFT'], window='3mo') confidence 0.94
"show me apple daily with volume"
plot_price(symbol='AAPL', resolution='1d', include_volume=True) confidence 0.75
"what tickers do you have"
list_symbols() confidence 1.00
这些调用会落到交易助手里十个普通函数上。它们的参数取值来自固定列表,所以本身就已经是 Literal:
def plot_price(
symbol: Literal["SPY", "NVDA", "AMD", "AAPL", "MSFT", "TSLA"],
style: Literal["line", "candles"] = "line",
resolution: Literal["1m", "5m", "15m", "1h", "1d"] = "15m",
window: Literal["1d", "1w", "1mo", "3mo"] = "1w",
include_volume: bool = False,
moving_average: Literal["9", "20", "50"] | None = None,
log_scale: bool = False,
): ...
取值来自固定列表的参数构成一个封闭集合。当它从该列表中取一个值时,它就会得到一个恰好覆盖这些值的 Choice 问题,因此到达函数的值一定是函数能接受的值。你不需要改动这些函数。你要添加的是一份 spec,用平实的话说明每个参数的含义。到最后你会得到一个 Dispatcher,可以指向你自己的函数。
环境准备
pip install ipython polars matplotlib numpy "typesafe-sdk>=0.5.7" cooksafe --extra-index-url https://pypi.typesafe.ai/
设置 TYPESAFE_API_KEY。本文件旁边有两个模块。trader.py 存放这十个函数,以及一个从缓存读取答案的 TypeSafe 客户端,这样重新渲染时无需调用 API 就能复现下面的数字。dispatch.py 存放读取签名与 spec 并完成调用的代码。
import json
from pathlib import Path
from cooksafe import make_playground_link
from dispatch import ROUTE, Dispatcher, closed_sets
from IPython.display import Markdown, display
from trader import TOOLS, client, load
TYPESAFE_MODEL = "jev-1.12"
print(f"{len(TOOLS)} functions over {load().height:,} one-minute bars")
10 functions over 156,780 one-minute bars
在签名中找出封闭集合
类型提示已经说明了哪些参数取值来自固定列表,以及每个列表里有什么。closed_sets 读取一个签名,把这些参数归入三种形态:choice(一个 Literal,即从列表中取一个值)、set(一个 list[Literal[...]],即可取任意多个),或 flag(一个 bool,即开或关)。这十个函数都定义在 trader.py 中。
for name, fn in TOOLS.items():
shapes = closed_sets(fn)
print(
f" {name:<20}{len(shapes)} "
+ ", ".join(f"{a}:{s}" for a, (s, _) in shapes.items())
)
print(
f"\n{sum(len(closed_sets(fn)) for fn in TOOLS.values())} fillable arguments in total"
)
list_symbols 0
market_summary 1 window:choice
plot_price 7 symbol:choice, style:choice, resolution:choice, window:choice, include_volume:flag, moving_average:choice, log_scale:flag
intraday_pattern 3 symbol:choice, window:choice, metric:choice
compare_returns 3 symbols:set, window:choice, normalize:flag
rolling_correlation 4 symbol:choice, benchmark:choice, window:choice, resolution:choice
summary_stats 2 symbol:choice, window:choice
volatility 3 symbol:choice, window:choice, annualized:flag
top_movers 2 window:choice, direction:choice
drawdown 3 symbol:choice, window:choice, plot:flag
28 fillable arguments in total
top_movers 展示了什么会被排除在外。它的三个参数中有两个是封闭集合。第三个 limit 是 int,因此它永远不会得到问题,并保留默认值 3。自由文本、数字和日期同理:不生成问题,函数的默认值继续生效。
编写 spec
Literal 给了你字符串 "1mo" 和 "3mo",但它没有说明用户输入「this quarter」时指的是后者。spec 来说明这一点。它为每个参数持有一个问题,为每个选项持有一行说明,为每个函数持有一段描述,外加一个在多个函数之间做选择的问题。它存放在 spec.json 中,可以由一个大语言模型(LLM)根据签名替你写出来。
SPEC = json.loads(Path("spec.json").read_text())
for argument in ("style", "moving_average"):
print(
json.dumps(
{argument: SPEC["functions"]["plot_price"]["arguments"][argument]}, indent=2
)
)
{
"style": {
"question": "Does the user want a plain line or candles?",
"stated": "Does the user say how the chart should be drawn, such as a line, candles, or OHLC bars?",
"options": {
"line": "a simple line through the closing prices",
"candles": "a candlestick or OHLC chart, showing each bar's open, high, low and close"
}
}
}
{
"moving_average": {
"question": "How many bars should the moving average cover - nine, twenty, or fifty?",
"stated": "Does the user ask for a moving average or a smoothed line over the candles?",
"options": {
"9": "a nine-bar moving average, a fast one",
"20": "a twenty-bar moving average",
"50": "a fifty-bar moving average, a slow one"
}
}
}
选项的键就是函数接受的字符串,因此之后无需再把某个标签映射回参数。stated 使参数变为可选。它是第二个是/否问题,询问这条命令是否对该参数有所提及。当答案为否时,调用会略去该参数,由函数自身的默认值生效。
集合参数会为每个成员各生成一次问题,其中 {} 代表成员名。"Does the user want {} in the comparison?" 会变成每个股票代码一个问题。
每个问题要围绕概念来写,而不是围绕用户可能选用的字眼,因为匹配是基于含义的:「is amd tracking nvidia lately」能命中 rolling_correlation,尽管 tracking 和 lately 都没有出现在 spec.json 的任何地方。不要用参数名来命名问题——"Which resolution?" 让命令没有任何可匹配的内容。
把 spec 变成问题
Dispatcher 只根据 spec 构建一次问题。此后每条命令就是一次请求,携带函数的选择以及每个函数的全部参数,而 dispatcher 只读取被选中函数的答案。
assistant = Dispatcher(SPEC, TOOLS, client)
print(f"{len(assistant.questions)} questions per command, among them:")
for qid in (
"__tool__",
"plot_price.style",
"plot_price.style?",
"compare_returns.symbols.NVDA",
):
question = assistant.questions[qid]
print(f" {qid:<30}{question['type']:<8}{str(question['instructions'])[:64]}")
54 questions per command, among them:
__tool__ choice What is the user asking the trading assistant to do?
plot_price.style choice Does the user want a plain line or candles?
plot_price.style? noul Does the user say how the chart should be drawn, such as a line,
compare_returns.symbols.NVDA noul Does the user want NVDA in the comparison?
运行十四条命令
一条请求占一行,其 confidence 是该调用背后最不确定的那个判断。
COMMANDS = [
"show nvda 1h",
"plot rolling correlation between nvda and spy for the past month",
"when during the day does nvda trade the most",
"what moved today",
"what tickers do you have",
"how did the market do this week",
"candles for tesla with a 20 period moving average",
"compare nvda amd and msft over the past three months",
"how volatile is tsla",
"biggest losers today",
"worst drawdown for nvda this quarter, and chart it please",
"spy stats for the last month",
"show me apple daily with volume",
"is amd tracking nvidia lately",
]
CALLS = {command: assistant(command) for command in COMMANDS}
for command, call in CALLS.items():
print(f' "{command}"')
print(
f" {str(call):<66}confidence {call.confidence:.2f}"
f" tool {call.tool.probability:.2f}"
)
"show nvda 1h"
plot_price(symbol='NVDA', resolution='1h') confidence 0.78 tool 1.00
"plot rolling correlation between nvda and spy for the past month"
rolling_correlation(symbol='NVDA', benchmark='SPY', window='1mo') confidence 0.91 tool 1.00
"when during the day does nvda trade the most"
intraday_pattern(symbol='NVDA') confidence 0.53 tool 1.00
"what moved today"
top_movers(window='1d', direction='gainers') confidence 0.90 tool 0.90
"what tickers do you have"
list_symbols() confidence 1.00 tool 1.00
"how did the market do this week"
market_summary(window='1w') confidence 0.96 tool 0.99
"candles for tesla with a 20 period moving average"
plot_price(symbol='TSLA', style='candles', moving_average='20') confidence 0.69 tool 0.97
"compare nvda amd and msft over the past three months"
compare_returns(symbols=['NVDA', 'AMD', 'MSFT'], window='3mo') confidence 0.94 tool 1.00
"how volatile is tsla"
volatility(symbol='TSLA') confidence 0.96 tool 1.00
"biggest losers today"
top_movers(window='1d', direction='losers') confidence 0.98 tool 0.98
"worst drawdown for nvda this quarter, and chart it please"
drawdown(symbol='NVDA', window='3mo', plot=True) confidence 0.84 tool 0.84
"spy stats for the last month"
summary_stats(symbol='SPY', window='1mo') confidence 0.88 tool 0.88
"show me apple daily with volume"
plot_price(symbol='AAPL', resolution='1d', include_volume=True) confidence 0.75 tool 0.85
"is amd tracking nvidia lately"
rolling_correlation(symbol='AMD', benchmark='NVDA') confidence 0.82 tool 0.82
两条长命令都按要求给出了结果。「plot rolling correlation between nvda and spy for the past month」用一句话填好了四个参数。其中 symbol 和 benchmark 取自同样这六个股票代码,而每个代码都落到了正确的参数上,因为问题把各自的角色讲清楚了:先被提到的、被测量的那一个 对应 后提到的、作为基准的那一个。「compare nvda amd and msft over the past three months」把三个代码放进集合,把另外三个排除在外。
运行其中三条:
for command in (
"plot rolling correlation between nvda and spy for the past month",
"compare nvda amd and msft over the past three months",
"when during the day does nvda trade the most",
):
print(f'"{command}" -> {CALLS[command]}')
display(CALLS[command].run())
"plot rolling correlation between nvda and spy for the past month" -> rolling_correlation(symbol='NVDA', benchmark='SPY', window='1mo')
"compare nvda amd and msft over the past three months" -> compare_returns(symbols=['NVDA', 'AMD', 'MSFT'], window='3mo')
"when during the day does nvda trade the most" -> intraday_pattern(symbol='NVDA')
再看以文本作答的那几条:
for command in ("how did the market do this week", "biggest losers today"):
print(f'"{command}" -> {CALLS[command]}')
print(CALLS[command].run(), "\n")
"how did the market do this week" -> market_summary(window='1w')
the board over 1w
NVDA 254.12 9.62% 389,465,563
AMD 184.20 1.51% 182,740,497
AAPL 258.71 0.97% 223,818,998
SPY 664.86 0.40% 138,617,365
MSFT 451.35 0.26% 113,427,173
TSLA 320.22 -0.97% 266,317,023
"biggest losers today" -> top_movers(window='1d', direction='losers')
top 3 losers over 1d
AMD -0.57% -> 184.20
MSFT 0.67% -> 451.35
AAPL 1.40% -> 258.71
读懂置信度
confidence 报告的是调用中最不确定的那个判断,而不是所有判断的乘积,因为只要有一个参数错了,结果就毁了。乘积回答的是另一个问题(「每一部分都对吗」),而且随着函数参数变多它会下降,无论其中是否有哪个判断不牢靠。
这个数字从何而来,逐个参数来看:
call = CALLS["is amd tracking nvidia lately"]
print(f'"is amd tracking nvidia lately" -> {call} confidence {call.confidence:.2f}')
for name, argument in call.arguments.items():
top = sorted(argument.distribution.items(), key=lambda kv: -kv[1])[:3]
shown = "omitted, default stands" if argument.omitted else repr(argument.value)
print(
f" {name:<12}{shown:<26}p {argument.probability:.2f} "
+ " ".join(f"{k} {v:.2f}" for k, v in top)
)
print(f" weakest argument: {call.weakest().name}")
"is amd tracking nvidia lately" -> rolling_correlation(symbol='AMD', benchmark='NVDA') confidence 0.82
symbol 'AMD' p 0.87 AMD 0.87 NVDA 0.13 AAPL 0.00
benchmark 'NVDA' p 0.78 NVDA 0.92 AMD 0.08 AAPL 0.00
window omitted, default stands p 0.96
resolution omitted, default stands p 0.99
weakest argument: benchmark
这里 window 和 resolution 都被略去了,因为「lately」没有说明回溯多久、用哪种 K 线,于是 rolling_correlation 按自身默认值运行:一个月、小时级 K 线。这正是 stated 问题的作用。没有它,choice 就必须指定某个 window,而且会很有信心地指定一个。
在 playground 中打开
下面的链接包含一条命令以及它所选函数的那些问题:在十段函数描述之间做的 choice,以及 rolling_correlation 的四个参数。在那里修改命令,参数会随之变化。
COMMAND = "plot rolling correlation between nvda and spy for the past month"
picked = CALLS[COMMAND]
playground_link = make_playground_link(
COMMAND,
{ROUTE: assistant.questions[ROUTE]}
| {q: v for q, v in assistant.questions.items() if q.startswith(f"{picked.name}.")},
models=[TYPESAFE_MODEL],
)
display(
Markdown(
f"🔗 [Open the command and its questions in the TypeSafe playground]({playground_link})"
)
)