quantlab-factor-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. factor_agent/KUNQUANT_LICENSE +201 -0
  2. factor_agent/__init__.py +8 -0
  3. factor_agent/_acceptance_model.py +93 -0
  4. factor_agent/_admission.py +168 -0
  5. factor_agent/_expression_fold.py +48 -0
  6. factor_agent/_feedback.py +399 -0
  7. factor_agent/_max_drawdown.py +51 -0
  8. factor_agent/_model_cache.py +100 -0
  9. factor_agent/_scheduler.py +35 -0
  10. factor_agent/acceptance.py +808 -0
  11. factor_agent/accepted/__init__.py +1 -0
  12. factor_agent/aggregations.py +122 -0
  13. factor_agent/campaign.py +797 -0
  14. factor_agent/candidate.py +51 -0
  15. factor_agent/config.py +651 -0
  16. factor_agent/derived.py +98 -0
  17. factor_agent/evaluator.py +1096 -0
  18. factor_agent/events.py +343 -0
  19. factor_agent/exported.py +177 -0
  20. factor_agent/gate/__init__.py +359 -0
  21. factor_agent/gate/_canonical.py +331 -0
  22. factor_agent/gate/_checks.py +85 -0
  23. factor_agent/gate/_constraints.py +85 -0
  24. factor_agent/gate/_dimension.py +177 -0
  25. factor_agent/gate/_originality.py +146 -0
  26. factor_agent/gate/_sandbox.py +105 -0
  27. factor_agent/gate/graph.py +223 -0
  28. factor_agent/gate/stages.py +383 -0
  29. factor_agent/gate/vocabulary.py +1097 -0
  30. factor_agent/ledger.py +85 -0
  31. factor_agent/llm/__init__.py +0 -0
  32. factor_agent/llm/_common.py +18 -0
  33. factor_agent/llm/_raw.py +78 -0
  34. factor_agent/llm/anthropic.py +100 -0
  35. factor_agent/llm/base.py +124 -0
  36. factor_agent/llm/claude_code.py +101 -0
  37. factor_agent/llm/log.py +74 -0
  38. factor_agent/llm/openai_compatible.py +113 -0
  39. factor_agent/llm/replay.py +59 -0
  40. factor_agent/llm/scripted.py +39 -0
  41. factor_agent/period_lag.py +91 -0
  42. factor_agent/prompts.py +557 -0
  43. factor_agent/researcher.py +615 -0
  44. factor_agent/scoring.py +143 -0
  45. factor_agent/segments.py +184 -0
  46. factor_agent/session.py +500 -0
  47. factor_agent/snapshot.py +219 -0
  48. factor_agent/web/__init__.py +7 -0
  49. factor_agent/web/_acceptance.py +145 -0
  50. factor_agent/web/_config.py +51 -0
  51. factor_agent/web/_diagnosis.py +110 -0
  52. factor_agent/web/_files.py +78 -0
  53. factor_agent/web/_health.py +207 -0
  54. factor_agent/web/_ic.py +27 -0
  55. factor_agent/web/_ledger.py +293 -0
  56. factor_agent/web/_memory.py +42 -0
  57. factor_agent/web/_results.py +77 -0
  58. factor_agent/web/_round.py +219 -0
  59. factor_agent/web/_trials.py +112 -0
  60. factor_agent/web/app.py +118 -0
  61. factor_agent/web/run_view.py +329 -0
  62. factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
  63. factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
  64. factor_agent/web/static/index.html +13 -0
  65. factor_agent/zoo.py +136 -0
  66. quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
  67. quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
  68. quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
  69. quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
  70. quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
  72. quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,557 @@
1
+ """Prompt templates. They describe the data, the vocabulary and the training-segment feedback, never dates or returns.
2
+
3
+ Each role's prompt opens with the role, its task and who uses its answer,
4
+ holds the long inputs in the middle, each inside an XML tag (``<quota>``,
5
+ ``<directions>``, ``<refused_candidates>``, ``<candidates>``, ``<previous_memory>``, ``<accepted>``, ``<history>``,
6
+ and the context sections under their keys), and ends by restating the task
7
+ and the shape of the answer. A fenced ``json`` block holds only that shape,
8
+ the last thing in the prompt, so no input reads like an answer to copy.
9
+
10
+ The templates are built in (``TEMPLATES``) and any of them may be replaced
11
+ from a file (``Templates.load``). A template names what it is filled with
12
+ as ``{placeholder}``; ``PLACEHOLDERS`` lists, per template, the ones it must
13
+ use and the ones it may. Every template may use ``market`` (the config's
14
+ description of the data) and ``horizon`` (the bars the label spans), and
15
+ every one but ``data``, which is the same in every round, ``round``.
16
+ ``Prompts`` fills a template with those and with the data
17
+ section, itself the ``data`` template filled with the market, the
18
+ described inputs of the vocabulary, the scoring's sentence and the horizon.
19
+ When the vocabulary opens an intraday family, the code rules the proposer
20
+ is given end with ``INTRADAY`` (the two stages, their rules and
21
+ ``INTRADAY_EXAMPLES``), and the vocabulary lists its operators by stage;
22
+ without one the prompts are those of a daily campaign.
23
+ """
24
+
25
+ import string
26
+ from collections.abc import Mapping
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ from factor_agent.gate import _dimension
31
+ from factor_agent.gate.vocabulary import Vocabulary
32
+
33
+ DATA = """\
34
+ The data: {market}.
35
+ {scoring}
36
+ The label a factor is scored against is the return over the next {horizon} bars.
37
+ Only a training period is ever evaluated; its dates are not disclosed.
38
+
39
+ The inputs, by family:
40
+ {inputs}"""
41
+
42
+ VOCABULARY = """\
43
+ Inputs: {inputs}
44
+ Operators:
45
+ {operators}
46
+ Parameter values (windows, lags and constants must be one of these; constants may be negative):
47
+ {parameters}
48
+ Builtins: {builtins}"""
49
+
50
+ CODE_RULES = """\
51
+ Write each factor as Python code over KunQuant operators. The code runs in a sandbox that builds a
52
+ KunQuant graph from it, so it uses only Python's own constructs and the names listed in <vocabulary>:
53
+ it may assign variables, define functions, and use for/while/if and `with Builder(loop):` blocks for
54
+ window loops. The sandbox refuses import, class, attribute access and file or network access, because
55
+ the graph is all that is evaluated. The code ends by assigning one operator to `alpha`, the factor's
56
+ value. Arithmetic operators (+, -, *, /) and comparisons work on operators; a comparison serves only
57
+ as the condition of a Select.
58
+ A factor's value should be comparable across the cross-section, so write it as a pure number: a
59
+ ratio, a return, a rank or a z-score. An output that carries a unit (a price, a dollar amount, a share
60
+ count) ranks the cross-section partly by that level, and that ranking is what the scoring compares,
61
+ unless the mechanism is about that level, as a price impact per dollar traded is.
62
+ Leave a missing value missing. The scoring skips a symbol without a value that day, so a missing
63
+ value needs no fill; a filled value is ranked as real data, so a symbol without the data is sorted as
64
+ if its value were the fill and the factor partly sorts the cross-section by whether the item is
65
+ reported; and coverage is measured before any fill, so a fill does not help a factor pass the
66
+ coverage rule. Fill (SetInfOrNanToValue) only when the mechanism says what a missing value means,
67
+ as an unreported R&D expense is zero, and state that reason in the description."""
68
+
69
+ #: What the code rules add when the vocabulary opens an intraday family: the two stages, their rules,
70
+ #: what the aggregations give, and ``INTRADAY_EXAMPLES``.
71
+ INTRADAY = """\
72
+
73
+ Intraday candidates. The fields of {family} are intraday: they hold a value for each bar of each
74
+ session (the bars of one regular trading day), not one per day. Code that reads them is written in two
75
+ stages, still as one piece of code ending in `alpha`:
76
+ - the intraday stage computes on each session's bars with the operators <vocabulary> allows in it, and
77
+ ends in session aggregations (SessionMean, SessionSum, ...), each reducing a session to one value per
78
+ symbol and day;
79
+ - the daily stage works on those values as on any daily field, with the daily operators, the daily
80
+ windows and the daily fields beside them.
81
+ The gate refuses a candidate that breaks one of these rules:
82
+ - an intraday field reaches the daily stage only through a session aggregation;
83
+ - the intraday stage reads no daily field, so a daily value enters only after an aggregation;
84
+ - an aggregation's inputs are intraday: no aggregation of a daily value or of another aggregation;
85
+ - each operator is used only in the stages <vocabulary> lists it under, so exponential averages, whose
86
+ memory has no end, are daily only;
87
+ - a window in the intraday stage counts bars and is one of the intraday windows ({windows}), a
88
+ lag one of the intraday lags ({lags}); in the daily stage either counts days and is one of the
89
+ daily values.
90
+ An intraday window stays within its session: it is missing on a session's first bars until it fits, so
91
+ it never reads the session before. A cross-sectional operator in the intraday stage ranks across the
92
+ market at each bar. A price field is missing on a bar without a trade, and a volume or trade count is 0
93
+ there. An aggregation uses the bars where its input has a value: SessionFirst and SessionLast take the
94
+ first and last value present, and a session without one gives a missing value, except SessionCount and
95
+ SessionSum, which give 0.
96
+ {examples}"""
97
+
98
+ #: Complete example intraday candidates, each a description and its code; they pass the Gate on the
99
+ #: example minute-bar field file (``configs/fields/massive_minute_bars.yaml``) and operator file.
100
+ INTRADAY_EXAMPLES = (
101
+ (
102
+ "the share of the session's volume traded in its last 60 bars, averaged over 20 days",
103
+ "late = SessionSum(Select(session_bars_left < 60, bar_volume, ConstantOp(0)))\n"
104
+ "alpha = WindowedAvg(late / SessionSum(bar_volume), 20)",
105
+ ),
106
+ (
107
+ "the session's volatility of 5-bar returns, averaged over 5 days, against the 20-day volatility of daily returns",
108
+ "intraday_volatility = SessionStd(last_price / BackRef(last_price, 5) - 1)\n"
109
+ "daily_volatility = WindowedStddev(close / BackRef(close, 1) - 1, 20)\n"
110
+ "alpha = WindowedAvg(intraday_volatility, 5) / daily_volatility",
111
+ ),
112
+ )
113
+
114
+ FIELDS = """\
115
+ - "hypothesis": the economic mechanism that makes the factor predict the return, in one or two sentences
116
+ - "mechanism_type": the mechanism type of its research direction
117
+ - "horizon_rationale": why the effect plays out over about {horizon} bars
118
+ - "description": what the code computes, in words
119
+ - "code": the Python code, ending with the assignment to `alpha`"""
120
+
121
+ #: The shape of one candidate in the proposer's answer (braces doubled for ``str.format``).
122
+ _CANDIDATE_SKELETON = """\
123
+ ```json
124
+ [
125
+ {{
126
+ "hypothesis": "<string>",
127
+ "mechanism_type": "<string>",
128
+ "horizon_rationale": "<string>",
129
+ "description": "<string>",
130
+ "code": "<string>"
131
+ }}
132
+ ]
133
+ ```"""
134
+
135
+ PLAN = """\
136
+ You are the research planner of a search for cross-sectional factors on {market}, each built from a
137
+ stated economic mechanism. You plan this round's research directions; the proposer then writes one
138
+ candidate factor for each research direction you plan and decides how to compute it. So state each
139
+ research direction as the mechanism and the data it reads, and leave the formula, its windows and its
140
+ ratios to the proposer: a direction written as a known formula tends to come back as that textbook
141
+ factor, while a mechanism leaves room for a new way to measure it.
142
+
143
+ Round: {round}
144
+
145
+ <data>
146
+ {data}
147
+ </data>
148
+
149
+ {context}
150
+
151
+ The mechanism types:
152
+ <mechanism_types>
153
+ {mechanisms}
154
+ </mechanism_types>
155
+
156
+ {actions}
157
+
158
+ <quota>
159
+ {quota}
160
+ </quota>
161
+
162
+ Now plan this round's research directions: for each mechanism type, as many as <quota> gives it.
163
+ Each research direction is an object with these fields:
164
+ - "mechanism_type": one of the mechanism types in <quota>
165
+ - "action": "new", "mutation" or "refinement"
166
+ - "parent": the id of the trial a mutation or a refinement builds on, "" for a new research direction
167
+ - "direction": the mechanism to pursue and the data it reads, in one or two sentences
168
+
169
+ Answer with a JSON array of research directions, one element per research direction, each with these
170
+ fields and no others:
171
+ ```json
172
+ [
173
+ {{
174
+ "mechanism_type": "<string>",
175
+ "action": "<new, mutation or refinement>",
176
+ "parent": "<string>",
177
+ "direction": "<string>"
178
+ }}
179
+ ]
180
+ ```
181
+ """
182
+
183
+ #: The planner's actions (``plan``'s ``actions``) when it reads the earlier trials.
184
+ ACTION_RULES = """\
185
+ Each research direction takes one of these actions:
186
+ - "new": a mechanism not tried before; its "parent" is "".
187
+ - "refinement": the mechanism of an earlier evaluated trial, accepted or refused, measured another
188
+ way: to remove what refused it (a residual t below the threshold, a correlation with an existing
189
+ factor, deciles that do not rise in the factor's direction) or to strengthen an accepted factor.
190
+ Its "parent" is that trial's id. Change how the mechanism is measured, because a version that
191
+ changes only the windows or the parameters is refused.
192
+ - "mutation": another economic channel an earlier evaluated trial's result points to, such as the
193
+ mechanism that would explain a factor direction it found the other way round, or a channel
194
+ orthogonal to an accepted factor; its "parent" is that trial's id.
195
+ A trial's id is the T<round>-<slot> that <recent_history>, <last_round> and <best> name it by, such as
196
+ T3-07. The proposer is given the parent trial's code, its statistics and why it was refused. A research
197
+ direction whose parent is not the id of an evaluated trial is planned as a new one."""
198
+
199
+ #: The planner's actions when it reads no earlier trial (``context.feedback`` off).
200
+ ONLY_NEW = """\
201
+ Every research direction is new: its "action" is "new" and its "parent" is ""."""
202
+
203
+ PROPOSE = """\
204
+ You are a quantitative researcher. You write one candidate factor for each research direction in
205
+ <directions>: a cross-sectional factor on {market}, built from a stated economic mechanism, that
206
+ predicts the return over the next {horizon} bars. Each candidate is checked by a static gate, judged
207
+ by a reviewer on whether its hypothesis, description and code agree, and then evaluated on a training
208
+ period.
209
+
210
+ Round: {round}
211
+
212
+ <data>
213
+ {data}
214
+ </data>
215
+
216
+ {context}
217
+
218
+ How to write the code:
219
+ {code_rules}
220
+
221
+ <vocabulary>
222
+ {vocabulary}
223
+ </vocabulary>
224
+
225
+ <directions>
226
+ {directions}
227
+ </directions>
228
+
229
+ Now write one candidate for each research direction in <directions> ({n} in all), in the same order.
230
+ Each candidate is an object with these fields:
231
+ {fields}
232
+
233
+ Answer with a JSON array of candidates, one per research direction ({n} in all). Element i is the
234
+ candidate for research direction i, because the candidates are matched to the research directions by
235
+ position; give each one these fields and no others:
236
+ """ + _CANDIDATE_SKELETON + "\n"
237
+
238
+ RETRY = """\
239
+ You are a quantitative researcher correcting candidate factors that were refused before evaluation.
240
+ Each entry in <refused_candidates> holds the research direction a candidate was written for
241
+ ("direction"), the refused version ("refused_version"), why it was refused ("refused_because") and,
242
+ for some, how to write it instead ("how_to_write_it"). An entry the reviewer sent back also holds the
243
+ unit of the refused version's value ("output_unit"), such as "pure number" or "price"; a unit other
244
+ than a pure number is normalised, for example by dividing by a price, unless the mechanism is about
245
+ that level. Your corrected candidates
246
+ go through the same checks again, then to the reviewer and the evaluation on a training period.
247
+ The factors are on {market}; each predicts the return over the next {horizon} bars.
248
+
249
+ Round: {round}
250
+
251
+ How to write the code:
252
+ {code_rules}
253
+
254
+ <vocabulary>
255
+ {vocabulary}
256
+ </vocabulary>
257
+
258
+ <refused_candidates>
259
+ {failures}
260
+ </refused_candidates>
261
+
262
+ Now correct every entry in <refused_candidates> ({n} in all), in the same order. Change the code or the
263
+ fields so that the candidate passes and still expresses its mechanism and the idea of its research
264
+ direction. When the reason is that it duplicates an earlier candidate, write a different mechanism of
265
+ the same mechanism type instead.
266
+
267
+ Return each corrected candidate object itself, an object with these fields:
268
+ {fields}
269
+ Leave out "number", "direction", "output_unit", "refused_because" and "how_to_write_it", and write
270
+ the fields at the top level of the object rather than nested inside another object, because element i
271
+ of your array is read directly as the corrected candidate for entry i.
272
+
273
+ Answer with a JSON array of corrected candidates, one per entry ({n} in all). Element i answers entry
274
+ i of <refused_candidates>; give each one these fields and no others:
275
+ """ + _CANDIDATE_SKELETON + "\n"
276
+
277
+ REVIEW = """\
278
+ You are the reviewer of candidate factors on {market}, each meant to predict the return over the
279
+ next {horizon} bars. You judge, for each candidate in <candidates>, whether its hypothesis, its
280
+ description and its code agree; a candidate you find inconsistent is sent back once to be rewritten
281
+ with your reason and is dropped if it is still inconsistent, and one you find consistent is evaluated
282
+ on a training period.
283
+
284
+ Round: {round}
285
+
286
+ The data, with every field a candidate's code may read and what it holds:
287
+ <data>
288
+ {data}
289
+ </data>
290
+
291
+ Each candidate is listed with its "output_unit": the unit of its value, as the static gate infers it
292
+ from the units of the fields, such as "pure number" (a ratio, a return, a rank or a z-score), "price",
293
+ "money", "volume" or "1/dollars". It is "unknown" when the gate cannot tell; then judge the unit from
294
+ the code.
295
+
296
+ <candidates>
297
+ {candidates}
298
+ </candidates>
299
+
300
+ A candidate is inconsistent when any of these holds:
301
+ 1. The code computes something the description does not say.
302
+ 2. The description says something the code does not compute.
303
+ 3. The code leaves out an input or a step the hypothesis needs.
304
+ 4. What the code computes is not the quantity the hypothesis says drives the return.
305
+ 5. The code does not use an input, a transformation or a quantity the hypothesis says the factor is
306
+ built from. What the hypothesis mentions only as background, as a consequence or as a contrast
307
+ does not count, because the factor need not be built from it. For example, a hypothesis that
308
+ prices under-react to earnings surprises, with code that reads only price and volume, is
309
+ inconsistent; a hypothesis that investors watch net income and overlook gross profitability,
310
+ with code built from gross profit, is consistent, because net income is only the contrast.
311
+ 6. The output has a unit other than a pure number ("output_unit"), and the hypothesis does not
312
+ explain why the cross-section is ranked by that level, because such a value ranks it partly by
313
+ price level or size. For example, the Amihud illiquidity, the absolute return per dollar traded
314
+ (1/dollars), is consistent when its hypothesis is about the price impact per dollar traded; a
315
+ slope of vwap described as a trend, without dividing by a price (price), is inconsistent.
316
+ 7. The code fills missing values (SetInfOrNanToValue) and the description does not state the reason,
317
+ what a missing value means in the mechanism, because a filled value is ranked as real data. For
318
+ example, R&D expense filled with 0 is consistent when the description says an unreported R&D
319
+ expense is zero; a ratio filled with 0 for no stated reason is inconsistent.
320
+ Otherwise it is consistent. Which way a factor predicts (its factor direction) is read from the data
321
+ afterwards, so a factor direction the text states, leaves out or gets the other way round is no reason
322
+ to find a candidate inconsistent. The length of the code is no reason to find a candidate consistent
323
+ or inconsistent: judge what the code computes. Your verdict is the whole answer, so judge each
324
+ candidate as it is written.
325
+
326
+ Now judge every candidate in <candidates>. Answer with a JSON array of verdicts, one per candidate:
327
+ element i is the verdict on candidate i, with that candidate's "number", a one-sentence "reason", and
328
+ then "consistent" (true or false). Give each verdict these fields and no others:
329
+ ```json
330
+ [
331
+ {{
332
+ "number": <integer>,
333
+ "reason": "<one sentence>",
334
+ "consistent": <true or false>
335
+ }}
336
+ ]
337
+ ```
338
+ """
339
+
340
+ #: The parts of the memory, by heading, in order: what the distiller is asked for (``DISTILL``) and
341
+ #: what its reply is parsed by (``factor_agent.researcher``). A replaced ``distill`` template must name each.
342
+ MEMORY_PARTS = ("Working mechanism patterns", "Failure constraints", "Directional findings")
343
+ #: Items a part of the memory keeps at most.
344
+ MEMORY_ITEMS = 6
345
+
346
+ DISTILL = """\
347
+ You are the keeper of the memory of a search for cross-sectional factors on {market}; the factors
348
+ predict the return over the next {horizon} bars. The planner and the proposer of the next rounds read
349
+ your memory in their context, next to the latest feedback, to choose which mechanisms to pursue and
350
+ how to write them, so it carries the lessons that the list of trials is too long to show. You receive
351
+ the previous memory, the trials since it was written and every factor accepted so far, and write the
352
+ next memory from them.
353
+
354
+ Round: {round}
355
+
356
+ <previous_memory>
357
+ {memory}
358
+ </previous_memory>
359
+
360
+ <accepted>
361
+ {accepted}
362
+ </accepted>
363
+
364
+ <history>
365
+ {history}
366
+ </history>
367
+
368
+ """ + f"""Now fold the new trials in <history> into the previous memory, in three parts:
369
+ - {MEMORY_PARTS[0]}: the kinds of mechanism and construction that were accepted, each with
370
+ why it worked; only the factors in <accepted> were, so this part holds those and nothing else,
371
+ and while <accepted> holds none it is the one item "- none accepted yet: <what the trials
372
+ suggest to try next>". Drop an item of the previous memory that is about no factor in <accepted>.
373
+ A factor a refusal names, a correlation with it being too high, is a factor that existed before
374
+ the search (of the reference set, or known) or one in <accepted>: it shows what a candidate
375
+ repeated, not one this search accepted.
376
+ - {MEMORY_PARTS[1]}: what keeps being refused, each as a rule to follow with its reason; a rule
377
+ needs refusals of several trials for the same reason, and a mechanism refused once or twice is
378
+ not yet one to avoid.
379
+ - {MEMORY_PARTS[2]}: which way the evaluated mechanisms worked, as the factor directions in
380
+ <history> show them, such as "volume surges were followed by outperformance".
381
+ Keep what the new trials do not contradict, merge items that say the same thing, and drop a finding
382
+ the new trials overturned. Write at most {MEMORY_ITEMS} items per part, the most useful first; later items are cut. Describe every pattern in words, because the next rounds
383
+ should reason about mechanisms rather than tune to numbers; so the memory holds no numbers,
384
+ statistics or factor code.
385
+
386
+ Answer with the memory as plain text, without a code fence, in this shape:
387
+ ```text
388
+ {MEMORY_PARTS[0]}
389
+ - <pattern>: <why it worked>
390
+ {MEMORY_PARTS[1]}
391
+ - <rule>: <why>
392
+ {MEMORY_PARTS[2]}
393
+ - <finding>
394
+ ```
395
+ """
396
+
397
+ #: The context sections in prompt order, by key and heading; an empty section is left out. Each is
398
+ #: shown under its heading inside the XML tag named by its key.
399
+ SECTIONS = (
400
+ ("seed", "Seed research directions"),
401
+ ("families", "Existing factor families (candidates must add to them, not rewrite them)"),
402
+ ("memory", "Memory distilled from earlier rounds"),
403
+ ("earlier_summary", "Earlier summary (the older trials, counted by mechanism type and outcome)"),
404
+ ("themes", "What the evaluated trials measure (the trials grouped by the correlation of their factors)"),
405
+ ("recent_history", "Recent history (the trials of the last rounds, one line each)"),
406
+ ("last_round", "Last round's feedback (training period only)"),
407
+ ("best", "Current best mechanisms (the accepted factors)"),
408
+ )
409
+
410
+ #: The built-in templates, by the name a config's ``prompts`` replaces them under.
411
+ TEMPLATES = {
412
+ "data": DATA, "plan": PLAN, "propose": PROPOSE, "retry": RETRY, "review": REVIEW, "distill": DISTILL,
413
+ }
414
+
415
+ #: What every template may use.
416
+ _COMMON = frozenset({"market", "horizon"})
417
+
418
+ #: Per template, the placeholders it must use and the ones it may use besides ``_COMMON``.
419
+ PLACEHOLDERS = {
420
+ "data": (frozenset({"market", "inputs", "scoring", "horizon"}), frozenset()),
421
+ "plan": (frozenset({"data", "context", "quota"}), frozenset({"round", "mechanisms", "actions"})),
422
+ "propose": (
423
+ frozenset({"data", "context", "code_rules", "vocabulary", "fields", "n", "directions"}),
424
+ frozenset({"round", "mechanisms"}),
425
+ ),
426
+ "retry": (
427
+ frozenset({"code_rules", "vocabulary", "fields", "n", "failures"}), frozenset({"round", "data", "mechanisms"}),
428
+ ),
429
+ "review": (frozenset({"candidates", "data"}), frozenset({"round", "mechanisms"})),
430
+ "distill": (frozenset({"memory", "history"}), frozenset({"round", "data", "mechanisms", "accepted"})),
431
+ }
432
+
433
+
434
+ class PromptError(ValueError):
435
+ """Replaced templates are wrong; the message lists every problem."""
436
+
437
+ def __init__(self, problems: list[str]):
438
+ self.problems = tuple(problems)
439
+ super().__init__("the prompt templates have problems:\n" + "\n".join(f"- {p}" for p in problems))
440
+
441
+
442
+ class Templates:
443
+ """The templates of a campaign: the built-in ones, any of them replaced from a file."""
444
+
445
+ def __init__(self, texts: Mapping[str, str]):
446
+ self._texts = {**TEMPLATES, **texts}
447
+
448
+ @classmethod
449
+ def load(cls, replaced: Mapping[str, str | Path]) -> "Templates":
450
+ """The built-in templates with those of ``replaced`` (template name to file) read from their files.
451
+
452
+ Raises
453
+ ------
454
+ PromptError
455
+ Naming every unknown template, unreadable file, malformed template, and
456
+ placeholder a template lacks or may not use.
457
+ """
458
+ problems, texts = [], {}
459
+ for name, path in replaced.items():
460
+ if name not in TEMPLATES:
461
+ problems.append(f"{name}: not a template; the templates are {list(TEMPLATES)}")
462
+ continue
463
+ try:
464
+ texts[name] = Path(path).read_text()
465
+ except OSError as exc:
466
+ problems.append(f"{name} ({path}): cannot be read ({exc})")
467
+ continue
468
+ problems += [f"{name} ({path}): {problem}" for problem in _placeholder_problems(name, texts[name])]
469
+ if problems:
470
+ raise PromptError(problems)
471
+ return cls(texts)
472
+
473
+ def format(self, name: str, **values) -> str:
474
+ """Template ``name`` filled with ``values``; values it does not name are ignored."""
475
+ return self._texts[name].format(**values)
476
+
477
+
478
+ @dataclass(frozen=True)
479
+ class Prompts:
480
+ """What the Researcher's prompts say: the templates and what fills every one of them.
481
+
482
+ ``market`` is the config's description of the data, ``horizon`` the
483
+ bars the label spans, ``scoring`` the scoring's sentence
484
+ (``Scoring.describe``), ``vocabulary`` what candidates write with and
485
+ ``mechanisms`` the mechanism types with their one-line descriptions, in order.
486
+ """
487
+
488
+ templates: Templates
489
+ market: str
490
+ horizon: int
491
+ scoring: str
492
+ vocabulary: Vocabulary
493
+ mechanisms: Mapping[str, str]
494
+
495
+ @property
496
+ def mechanism_types(self) -> tuple[str, ...]:
497
+ """The names of the mechanism types, in order: what a round is planned over and a candidate belongs to."""
498
+ return tuple(self.mechanisms)
499
+
500
+ def render(self, name: str, **values) -> str:
501
+ """Template ``name`` filled with ``values`` and every value common to the templates."""
502
+ common = {"market": self.market, "horizon": self.horizon}
503
+ common["data"] = self.templates.format(
504
+ "data", **common, scoring=self.scoring, inputs=inputs(self.vocabulary),
505
+ )
506
+ common.update(
507
+ mechanisms="\n".join(f"- {kind}: {text}" for kind, text in self.mechanisms.items()),
508
+ code_rules=code_rules(self.vocabulary), vocabulary=VOCABULARY.format(**self.vocabulary.prompt()),
509
+ fields=FIELDS.format(horizon=self.horizon),
510
+ )
511
+ return self.templates.format(name, **common, **values)
512
+
513
+
514
+ def code_rules(vocabulary: Vocabulary) -> str:
515
+ """How to write the code: ``CODE_RULES``, and ``INTRADAY`` with its examples when an intraday family is open."""
516
+ if vocabulary.intraday_family is None:
517
+ return CODE_RULES
518
+ examples = "\n".join(
519
+ f"Example {number}: {description}:\n" + "\n".join(f" {line}" for line in code.splitlines())
520
+ for number, (description, code) in enumerate(INTRADAY_EXAMPLES, 1)
521
+ )
522
+ return CODE_RULES + INTRADAY.format(
523
+ family=vocabulary.intraday_family, windows=", ".join(map(str, vocabulary.intraday_windows)),
524
+ lags=", ".join(map(str, vocabulary.intraday_lags)), examples=examples,
525
+ )
526
+
527
+
528
+ def inputs(vocabulary: Vocabulary) -> str:
529
+ """The fields of ``vocabulary`` by family, each with its unit (a derived field's follows from its formula)."""
530
+ lines = []
531
+ for family, description in vocabulary.families.items():
532
+ marked = f"{family}, the intraday family" if family == vocabulary.intraday_family else family
533
+ lines.append(f"- {marked}" + (f" ({description}):" if description else ":"))
534
+ for field in vocabulary.fields.values():
535
+ if field.family == family:
536
+ unit = f" ({_dimension.name(field.dimension)})" if field.dimension is not None else ""
537
+ lines.append(f" - {field.name}{unit}: {field.description}")
538
+ return "\n".join(lines)
539
+
540
+
541
+ def _placeholder_problems(name: str, text: str) -> list[str]:
542
+ """What is wrong with the placeholders of template ``name``'s ``text``."""
543
+ try:
544
+ used = {field for _, field, _, _ in string.Formatter().parse(text) if field is not None}
545
+ except ValueError as exc:
546
+ return [f"is not a valid template ({exc}); a literal brace is written twice"]
547
+ required, optional = PLACEHOLDERS[name]
548
+ problems = [f"lacks the placeholder {{{p}}}" for p in sorted(required - used)]
549
+ if name == "distill": # the distiller's reply is parsed by these headings, so it must be asked for them
550
+ problems += [f"lacks the memory part heading {part!r}" for part in MEMORY_PARTS if part not in text]
551
+ unknown = sorted(used - required - optional - _COMMON)
552
+ if unknown:
553
+ problems.append(
554
+ f"uses the unknown placeholder(s) {['{' + p + '}' for p in unknown]}; it may use "
555
+ f"{sorted(required | optional | _COMMON)}"
556
+ )
557
+ return problems