函数调用
函数调用
把自然语言的交易请求变成对普通类型化函数的调用:把函数名和闭集参数映射为带置信度的 TypeSafe 问题。
当你点一杯“large iced oat latte, no sweetener”时,咖啡师不会把你的句子写下来。他们在杯子上勾四个选项。这个 cookbook 对交易 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 'cooksafe>=0.2.0,<0.3.0'
设置 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 让一个参数可选。它是第二个是/否问题,问这条命令到底有没有提到那个参数。当答案是否时,这次调用就不带那个参数,函数自己的默认值生效。
一个 set 参数的每个成员各得到一个问题,用 {} 代替成员名。"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”把三个代码放进 set,另外三个排除在外。
运行其中三条:
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
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 用自己默认的一个月和小时线来跑。这就是 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})"
)
)
在 TypeSafe Playground 中打开这条命令及其问题 →