Documentação

Chamada a funções

Transforma pedidos de trading em linguagem natural em chamadas a funções tipadas normais, mapeando nomes de função e argumentos de conjunto fechado em perguntas do TypeSafe com noção de confiança.

Quando pedes um «latte grande com gelo e aveia, sem edulcorante», o barista não aponta a tua frase. Marca quatro opções num copo. Este cookbook faz o mesmo para uma API de trading: entra uma frase e sai um nome de função e os seus argumentos como enums avaliados, cada um com uma confiança.

"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

Essas chamadas vão para dez funções normais de um assistente de trading. Os seus argumentos assumem valores de listas fixas, por isso já são 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,
): ...

Um argumento cujos valores vêm de uma lista fixa é um conjunto fechado. Quando assume um valor dessa lista, recebe uma pergunta Choice sobre exatamente esses valores, por isso o que chega à função é um valor que a função aceita. Deixas as funções em paz. O que acrescentas é uma spec que diz por palavras simples o que significa cada argumento. No fim tens um Dispatcher que podes apontar às tuas próprias funções.

Configuração

pip install ipython polars matplotlib numpy 'cooksafe>=0.2.0,<0.3.0'

Define TYPESAFE_API_KEY. Dois módulos estão junto a este ficheiro. trader.py contém as dez funções, mais um cliente TypeSafe que lê as respostas de uma cache, por isso voltar a renderizar reproduz os números abaixo sem chamar a API. dispatch.py contém o código que lê uma assinatura e uma spec e faz a chamada.

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

Encontra os conjuntos fechados nas assinaturas

Os type hints já dizem que argumentos vêm de uma lista fixa, e o que há em cada lista. closed_sets lê uma assinatura e classifica esses argumentos em três formas: uma choice (um Literal, ou seja, um valor da lista), um set (um list[Literal[...]], ou seja, qualquer número deles) ou uma flag (um bool, ou seja, ligado ou desligado). As dez funções estão definidas em 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 mostra o que fica de fora. Dos seus três argumentos, dois são conjuntos fechados. O terceiro, limit, é um int, por isso nunca recebe uma pergunta e mantém o seu valor por omissão de 3. Texto livre, números e datas funcionam da mesma forma: sem pergunta, e o valor por omissão da função mantém-se.

Escreve a spec

O Literal dá-te as cadeias "1mo" e "3mo". Não diz que um utilizador que escreve «este trimestre» se refere à segunda. Isso diz a spec. Contém uma pergunta por argumento, uma linha por opção, uma descrição por função e mais uma pergunta que escolhe entre as funções. Vive no spec.json, e um LLM pode escrevê-la por ti a partir das assinaturas.

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"
    }
  }
}

As chaves das opções são as cadeias que a função aceita, por isso depois nada tem de mapear uma etiqueta de volta a um argumento. stated torna um argumento opcional. É uma segunda pergunta de sim/não que pergunta se o comando diz alguma coisa sobre esse argumento. Quando a resposta é não, a chamada omite esse argumento e aplica-se o próprio valor por omissão da função.

Um argumento de conjunto recebe a sua pergunta uma vez por membro, com {} a substituir o nome do membro. "Does the user want {} in the comparison?" torna-se uma pergunta por ticker.

Escreve cada pergunta sobre a ideia e não sobre as palavras que um utilizador possa escolher, porque a correspondência é pelo significado: «is amd tracking nvidia lately» chega a rolling_correlation mesmo que nem tracking nem lately apareçam em qualquer lugar do spec.json. Evita dar a uma pergunta o nome do seu parâmetro — "Which resolution?" não dá nada ao comando com que corresponder.

Transforma a spec em perguntas

Dispatcher constrói as perguntas a partir da spec uma vez. Cada comando é então um pedido que transporta a escolha da função e os argumentos de todas as funções, e o dispatcher lê apenas as respostas da função escolhida.

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?

Executa catorze comandos

Um pedido ocupa uma linha, e a sua confidence é o juízo menos certo por trás dessa chamada.

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

Ambos os comandos longos saíram como pedido. «plot rolling correlation between nvda and spy for the past month» preencheu quatro argumentos a partir de uma frase. Dois deles, symbol e benchmark, saem dos mesmos seis tickers, e cada ticker foi parar ao argumento certo porque as perguntas explicitam os papéis: o que está a ser medido, nomeado primeiro contra o segundo nomeado, a referência. «compare nvda amd and msft over the past three months» colocou três tickers no conjunto e deixou de fora os outros três.

Executando três deles:

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')
saída saída saída

E as que respondem em texto:

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

Lê a confiança

confidence reporta o juízo menos certo da chamada, em vez do produto de todos eles, já que um único argumento errado basta para estragar o resultado. Um produto responde a outra pergunta («está tudo certo?»), e cai à medida que uma função recebe mais argumentos, quer algum juízo seja frágil quer não.

De onde veio esse número, argumento por argumento:

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 e resolution são ambos omitidos aqui, porque «lately» não diz quanto tempo atrás nem com que barras, por isso rolling_correlation é executada com os seus próprios valores por omissão de um mês e barras horárias. É para isso que serve a pergunta stated. Sem ela, a choice teria de nomear alguma janela, e teria nomeado uma com toda a confiança.

Abre-o no playground

O link abaixo contém um comando e as perguntas da função que escolheu: a choice sobre as dez descrições de função e os quatro argumentos de rolling_correlation. Edita o comando aí e os argumentos mudam com ele.

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})"
    )
)
Abre o comando e as suas perguntas no playground do TypeSafe →