Documentation

Appel de fonctions

Transforme des demandes de trading en langage naturel en appels à des fonctions typées, en associant arguments à ensemble fermé et questions TypeSafe.

Quand tu commandes un « grand latte glacé à l’avoine, sans édulcorant », le barista n’écrit pas ta phrase. Il coche quatre options sur un gobelet. Ce cookbook fait la même chose pour une API de trading : une phrase entre, et il en sort un nom de fonction et ses arguments sous forme d’enums évalués, chacun avec une confiance.

"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

Ces appels vont à dix fonctions ordinaires d’un assistant de trading. Leurs arguments prennent des valeurs dans des listes fixes, donc ce sont déjà des 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,
): ...

Un argument dont les valeurs viennent d’une liste fixe est un ensemble fermé. Quand il prend une valeur dans cette liste, il reçoit une question Choice sur exactement ces valeurs, donc ce qui atteint la fonction est une valeur que la fonction accepte. Tu laisses les fonctions tranquilles. Ce que tu ajoutes, c’est une spec qui dit en mots simples ce que chaque argument signifie. À la fin, tu as un Dispatcher que tu peux pointer vers tes propres fonctions.

Configuration

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

Définis TYPESAFE_API_KEY. Deux modules se trouvent à côté de ce fichier. trader.py contient les dix fonctions, plus un client TypeSafe qui lit les réponses depuis un cache, donc un nouveau rendu rejoue les chiffres ci-dessous sans appeler l’API. dispatch.py contient le code qui lit une signature et une spec et fait l’appel.

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

Trouve les ensembles fermés dans les signatures

Les type hints disent déjà quels arguments viennent d’une liste fixe, et ce que contient chaque liste. closed_sets lit une signature et range ces arguments en trois formes : une choice (un Literal, donc une valeur de la liste), un set (un list[Literal[...]], donc n’importe quel nombre d’entre eux), ou un flag (un bool, donc activé ou désactivé). Les dix fonctions sont définies dans 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 montre ce qui est laissé de côté. Sur ses trois arguments, deux sont des ensembles fermés. Le troisième, limit, est un int, donc il ne reçoit jamais de question et garde sa valeur par défaut de 3. Le texte libre, les nombres et les dates fonctionnent pareil : pas de question, et le défaut de la fonction s’applique.

Écris la spec

Le Literal te donne les chaînes "1mo" et "3mo". Il ne dit pas qu’un utilisateur qui tape « ce trimestre » veut dire la seconde. C’est la spec qui le dit. Elle contient une question par argument, une ligne par option, une description par fonction, et une question de plus qui choisit entre les fonctions. Elle vit dans spec.json, et un LLM peut l’écrire pour toi à partir des signatures.

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

Les clés d’option sont les chaînes que prend la fonction, donc rien n’a besoin de reassocier une étiquette à un argument ensuite. stated rend un argument optionnel. C’est une seconde question oui/non qui demande si la commande dit quoi que ce soit sur cet argument. Quand la réponse est non, l’appel omet cet argument et le défaut propre à la fonction s’applique.

Un argument de type set reçoit sa question une fois par membre, avec {} à la place du nom du membre. "Does the user want {} in the comparison?" devient une question par ticker.

Écris chaque question sur l’idée plutôt que sur les mots qu’un utilisateur pourrait choisir, car la correspondance se fait sur le sens : « is amd tracking nvidia lately » atteint rolling_correlation même si ni tracking ni lately n’apparaît nulle part dans spec.json. Évite de nommer une question d’après son paramètre – "Which resolution?" ne donne à la commande rien avec quoi correspondre.

Transforme la spec en questions

Dispatcher construit les questions à partir de la spec une seule fois. Chaque commande est alors une requête qui porte le choix de fonction et les arguments de toutes les fonctions, et le dispatcher ne lit que les réponses de la fonction choisie.

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?

Exécute quatorze commandes

Une requête tient sur une ligne, et sa confidence est le jugement le moins sûr derrière cet appel.

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

Les deux commandes longues sont sorties comme demandé. « plot rolling correlation between nvda and spy for the past month » a rempli quatre arguments à partir d’une phrase. Deux d’entre eux, symbol et benchmark, puisent dans les mêmes six tickers, et chaque ticker a atterri dans le bon argument parce que les questions explicitent les rôles : celui qu’on mesure, nommé en premier face à le second nommé, l’étalon. « compare nvda amd and msft over the past three months » a mis trois tickers dans l’ensemble et laissé les trois autres de côté.

En exécuter trois :

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')
sortie sortie sortie

Et celles qui répondent en texte :

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

Lis la confiance

confidence rapporte le jugement le moins sûr de l’appel, plutôt que le produit de tous, puisqu’un seul argument faux suffit à gâcher le résultat. Un produit répond à une autre question (« chaque partie est-elle correcte ? »), et il baisse à mesure qu’une fonction prend plus d’arguments, qu’un jugement particulier soit bancal ou non.

D’où vient ce nombre, argument par argument :

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 et resolution sont tous deux omis ici, parce que « lately » ne dit ni depuis quand ni sur quelles barres, donc rolling_correlation s’exécute sur ses propres défauts d’un mois et de barres horaires. C’est à cela que sert la question stated. Sans elle, la choice devrait nommer une fenêtre, et elle en aurait nommé une avec assurance.

Ouvre-le dans le playground

Le lien ci-dessous contient une commande et les questions de la fonction qu’elle a choisie : la choice sur les dix descriptions de fonction, et les quatre arguments de rolling_correlation. Modifie la commande là-bas et les arguments changent avec elle.

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})"
    )
)
Ouvre la commande et ses questions dans le playground TypeSafe →