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