函式呼叫
把自然語言的交易請求變成對普通型別化函式的呼叫:把函式名和閉集參數對映為帶置信度的 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 中開啟這條命令及其問題 →