Документация

Вызов функций

Превращает запросы к торговле на естественном языке в вызовы обычных типизированных функций, сопоставляя имена функций и аргументы из закрытых множеств с вопросами TypeSafe, учитывающими уверенность.

Когда вы заказываете «большой айс-латте на овсяном молоке, без сахара», бариста не записывает вашу фразу. Он отмечает четыре опции на стакане. Этот 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". Он не говорит, что пользователь, набирающий «этот квартал», имеет в виду вторую. Это говорит 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 один раз. Затем каждая команда — это один запрос, несущий выбор функции и аргументы каждой функции, а диспетчер читает ответы только выбранной функции.

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» не говорит, как давно или на каких барах, поэтому rolling_correlation работает на своих значениях по умолчанию — одном месяце и часовых барах. Для этого и нужен вопрос stated. Без него choice пришлось бы назвать какое-то окно, и оно уверенно назвало бы его.

Открыть в 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})"
    )
)
Открыть команду и её вопросы в playground TypeSafe →