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,399 @@
1
+ """The context the planner and the proposer read: what is known, assembled from the ledger.
2
+
3
+ It shows only training-segment statistics: each candidate's IC and its
4
+ Newey-West t, its residual (partial) IC, what the pool explains of it and the
5
+ pool factors most correlated with it, the order of its deciles' returns (as
6
+ ranks), the nearest existing factor and the correlation with it, the nearest
7
+ known factor and its sampled correlation, the rejection reasons, and each
8
+ accepted factor's marginal weight and residual IC in the accepted pool. It
9
+ never shows a return, a date, or advice to change windows or parameters. The first round
10
+ has no history; it sees the data, the seed directions and the existing
11
+ factor families (the reference set's, then the known factors', marked as
12
+ known) by name and description, without any statistic.
13
+
14
+ Every trial is named by its id, ``T<round>-<slot>`` (``named``), by which a
15
+ research direction names the trial it builds on (``parent``).
16
+ """
17
+
18
+ import math
19
+ import re
20
+
21
+ from factor_agent._admission import direction, reason_kind
22
+ from factor_agent.evaluator import Contribution
23
+ from factor_agent.ledger import TrialRecord
24
+ from factor_agent.zoo import owner
25
+
26
+ _DECIMAL = re.compile(r"[-+]?\d+\.\d+")
27
+ _TRIAL_ID = re.compile(r"T(\d+)-(\d+)")
28
+
29
+
30
+ def first_recent_round(rounds: int, recent_rounds: int) -> int:
31
+ """The first round of the recent history after ``rounds`` rounds; the rounds before it are the earlier summary's."""
32
+ return rounds - recent_rounds + 1
33
+
34
+
35
+ def distill_due(rounds: int, memory_round: int, memory_every: int) -> bool:
36
+ """Whether the distiller is asked after ``rounds`` rounds, when it was last asked after ``memory_round``."""
37
+ return rounds - memory_round >= memory_every
38
+
39
+
40
+ def accepted_source(number: int) -> str:
41
+ """The zoo source of a factor accepted in round ``number``."""
42
+ return f"accepted in round {number}"
43
+
44
+
45
+ def accepted_label(record: TrialRecord) -> str:
46
+ """The name an accepted factor has in the zoo, the pool and the prompts (``ZooEntry.label``)."""
47
+ return f"{record.candidate['description']} ({accepted_source(record.round)})"
48
+
49
+
50
+ #: How the families section marks a known factor's class.
51
+ KNOWN_MARK = "known, not in the reference set, refused when rewritten"
52
+
53
+
54
+ def families(reference, known=()) -> str:
55
+ """One line per factor of the reference set, then per known factor: its owner's class, doc line and outputs.
56
+
57
+ The owner (``zoo.owner``) is the factor a wrapper such as
58
+ ``NeutralizedFactor`` shows; the doc line is the first line of its
59
+ class docstring. A known factor's class is marked with ``KNOWN_MARK``.
60
+ """
61
+ lines = []
62
+ for factor, mark in [(f, "") for f in reference] + [(f, f" ({KNOWN_MARK})") for f in known]:
63
+ names = factor.get_factor_names()
64
+ kind = type(owner(factor))
65
+ doc = (kind.__doc__ or "").strip().splitlines()
66
+ shown = ", ".join(names[:12]) + (", ..." if len(names) > 12 else "")
67
+ lines.append(f"- {kind.__name__}{mark}: {doc[0] if doc else 'no description'} ({len(names)} outputs: {shown})")
68
+ return "\n".join(lines)
69
+
70
+
71
+ def trial_id(number: int, slot: int) -> str:
72
+ """The id of the trial of round ``number`` in ``slot`` (from 0): ``T<round>-<slot from 1, two digits>``."""
73
+ return f"T{number}-{slot + 1:02d}"
74
+
75
+
76
+ def named(records: list[TrialRecord]) -> list[tuple[str, TrialRecord]]:
77
+ """``records`` with their ids; a round's records are in slot order, as the ledger holds them."""
78
+ slots: dict[int, int] = {}
79
+ pairs = []
80
+ for r in records:
81
+ slots[r.round] = slots.get(r.round, -1) + 1
82
+ pairs.append((trial_id(r.round, slots[r.round]), r))
83
+ return pairs
84
+
85
+
86
+ def history(
87
+ trials: list[tuple[str, TrialRecord]], statistics: bool = True, earlier: list[tuple[str, TrialRecord]] = (),
88
+ ) -> str:
89
+ """One line per trial (``named``). Without ``statistics`` no metric is shown and the reasons' decimals are masked.
90
+
91
+ ``earlier`` holds the run's trials (``named``), by which a trial's parent
92
+ is shown with its residual t, the measure its child is compared on.
93
+ """
94
+ by_id = dict(earlier)
95
+ lines = []
96
+ for trial, r in trials:
97
+ line = (
98
+ f"- {trial}, round {r.round}, {_direction(r, by_id if statistics else {})}: "
99
+ f"{r.candidate.get('description', '(unparsed)')} -> {outcome(r)}"
100
+ )
101
+ if r.evaluated:
102
+ line += f"; {direction_text(r.metrics['ic_mean'])}"
103
+ if statistics and r.evaluated:
104
+ m = r.metrics
105
+ line += (
106
+ f"; training IC {_num(m['ic_mean'], 4)} (t {_num(m['ic_t'], 2)}), "
107
+ f"residual IC {_num(m['partial_ic_mean'], 4)} (t {_num(m['partial_ic_t'], 2)})"
108
+ )
109
+ share = m.get("pool_share", float("nan"))
110
+ if share is not None and math.isfinite(share) and share > 0:
111
+ line += f", {share:.0%} explained by the pool"
112
+ if not statistics and r.reasons:
113
+ line += "; " + _DECIMAL.sub("#", " ".join(r.reasons))
114
+ lines.append(line)
115
+ return "\n".join(lines)
116
+
117
+
118
+ def earlier_summary(records: list[TrialRecord], mechanism_types: tuple[str, ...] = ()) -> str:
119
+ """The trials of ``records`` counted by mechanism type: trials, evaluated, accepted and each kind of refusal.
120
+
121
+ The types come in the order of ``mechanism_types``, then any other in
122
+ name order; a type without trials is left out. A refusal is counted by
123
+ the stage that refused it (parse, gate, review inconsistent, compile) and,
124
+ at admission, by the kind of its reason (``_admission.reason_kind``).
125
+ """
126
+ if not records:
127
+ return ""
128
+ by_type: dict[str, list[TrialRecord]] = {}
129
+ for r in records:
130
+ by_type.setdefault((r.direction or {}).get("mechanism_type", "?"), []).append(r)
131
+ order = [t for t in mechanism_types if t in by_type] + sorted(set(by_type) - set(mechanism_types))
132
+ rounds = sorted({r.round for r in records})
133
+ lines = [f"The trials of rounds {rounds[0]} to {rounds[-1]}, by mechanism type:"]
134
+ for kind in order:
135
+ trials = by_type[kind]
136
+ refused: dict[str, int] = {}
137
+ for r in trials:
138
+ if r.status != "accepted":
139
+ label = _refusal(r)
140
+ refused[label] = refused.get(label, 0) + 1
141
+ line = (
142
+ f"- {kind}: {len(trials)} trials, {sum(r.evaluated for r in trials)} evaluated, "
143
+ f"{sum(r.status == 'accepted' for r in trials)} accepted"
144
+ )
145
+ if refused:
146
+ line += "; refused: " + ", ".join(f"{label} {count}" for label, count in refused.items())
147
+ lines.append(line)
148
+ return "\n".join(lines)
149
+
150
+
151
+ #: Groups ``themes`` lists, the largest first; the rest are counted.
152
+ THEMES_LISTED = 30
153
+
154
+
155
+ def theme_groups(names: list[str], matrix, threshold: float) -> list[list[str]]:
156
+ """The tried trials ``names`` in groups, by the matrix of their correlations (rows and columns in ``names``' order).
157
+
158
+ The trials are taken in the order they were tried, by their ids
159
+ (``trial_id``: by round, then by slot), whatever the order of ``names``.
160
+ Each joins the group of the earlier trial it is most correlated with,
161
+ when that correlation is at least ``threshold`` in absolute value, and
162
+ opens a group of its own otherwise. The groups are in the order they
163
+ were opened, each in the order its trials were tried.
164
+ """
165
+ order = sorted(range(len(names)), key=lambda k: _trial_order(names[k]))
166
+ group_of: dict[int, int] = {}
167
+ groups: list[list[str]] = []
168
+ for position, j in enumerate(order):
169
+ earlier = [(abs(float(matrix[i][j])), i) for i in order[:position]]
170
+ best = max(((c, i) for c, i in earlier if math.isfinite(c)), default=(0.0, -1))
171
+ if best[0] >= threshold:
172
+ group_of[j] = group_of[best[1]]
173
+ groups[group_of[j]].append(names[j])
174
+ else:
175
+ group_of[j] = len(groups)
176
+ groups.append([names[j]])
177
+ return groups
178
+
179
+
180
+ def _trial_order(trial: str) -> tuple[int, int]:
181
+ """The round and the slot of a trial id (``trial_id``)."""
182
+ number, slot = _TRIAL_ID.fullmatch(trial).groups()
183
+ return int(number), int(slot)
184
+
185
+
186
+
187
+ def themes(
188
+ trials: list[tuple[str, TrialRecord]], names: list[str], matrix, threshold: float, sent_back: bool = False,
189
+ ) -> str:
190
+ """The evaluated trials (of ``named`` ``trials``) grouped by what their factors measure (``theme_groups``).
191
+
192
+ ``names`` and ``matrix`` are the tried trials and their correlations
193
+ (``Evaluator.tried``). Every group of two trials or more and every group
194
+ holding an accepted factor is listed, the largest first and at most
195
+ ``THEMES_LISTED``: the trial that opened it, its trials, how many were
196
+ accepted, its largest residual t in its trial's direction, the last
197
+ round it was tried in and its refusals by kind; the other groups are
198
+ counted. With ``sent_back`` the planner is told that a new direction's
199
+ candidate falling in a group is sent back to be rewritten.
200
+ """
201
+ if not names:
202
+ return ""
203
+ by_id = dict(trials)
204
+ groups = [[t for t in group if t in by_id] for group in theme_groups(names, matrix, threshold)]
205
+ groups = [g for g in groups if g]
206
+ shown = [g for g in groups if len(g) > 1 or any(by_id[t].status == "accepted" for t in g)]
207
+ shown.sort(key=lambda g: -len(g))
208
+ lines = [
209
+ f"Each evaluated trial is grouped with the earlier trial its factor is most correlated with (the mean "
210
+ f"daily correlation on a sample of the training dates), when that correlation is at least {threshold:g} "
211
+ f"in absolute value; a trial without one opens a group. A trial of a group measures what the trial that "
212
+ f"opened it measures, whatever its hypothesis says. A new research direction is a mechanism no group "
213
+ f"measures" + (": a new direction's candidate that falls in a group is sent back to be rewritten." if sent_back else ".")
214
+ ]
215
+ for group in shown[:THEMES_LISTED]:
216
+ records = [by_id[t] for t in group]
217
+ first = records[0]
218
+ accepted = [t for t in group if by_id[t].status == "accepted"]
219
+ best_t, best_id = max(
220
+ ((direction(r.metrics["ic_mean"]) * r.metrics["partial_ic_t"], t) for t, r in zip(group, records)
221
+ if math.isfinite(r.metrics["partial_ic_t"])),
222
+ default=(float("nan"), ""),
223
+ )
224
+ refused: dict[str, int] = {}
225
+ for r in records:
226
+ if r.status != "accepted":
227
+ refused[_refusal(r)] = refused.get(_refusal(r), 0) + 1
228
+ line = (
229
+ f"- opened by {group[0]} ({first.candidate.get('description', '')}): {len(group)} trial(s) "
230
+ f"({', '.join(group)}), {len(accepted)} accepted{' (' + ', '.join(accepted) + ')' if accepted else ''}"
231
+ )
232
+ if best_id:
233
+ line += f"; largest residual t in its direction {_num(best_t, 2)} ({best_id})"
234
+ line += f"; last tried in round {max(r.round for r in records)}"
235
+ if refused:
236
+ line += "; refused: " + ", ".join(f"{label} {count}" for label, count in refused.items())
237
+ lines.append(line)
238
+ if len(shown) > THEMES_LISTED:
239
+ lines.append(f"- {len(shown) - THEMES_LISTED} smaller group(s) of several trials, not listed")
240
+ if len(groups) > len(shown):
241
+ lines.append(f"- {len(groups) - len(shown)} group(s) of a single refused trial, not listed")
242
+ return "\n".join(lines)
243
+
244
+
245
+ def _refusal(r: TrialRecord) -> str:
246
+ """The kind of refusal of a trial that was not accepted."""
247
+ if r.stage == "admission":
248
+ return f"admission ({reason_kind(r.reasons[0]) if r.reasons else 'other'})"
249
+ return {"review": "review inconsistent", "evaluate": "compile"}.get(r.stage, r.stage)
250
+
251
+
252
+ def last_round(trials: list[tuple[str, TrialRecord]], earlier: list[tuple[str, TrialRecord]] = ()) -> str:
253
+ """Every candidate of a round (``named``) with its outcome, its statistics and why it was refused.
254
+
255
+ ``earlier`` holds the run's trials (``named``), by which a parent is
256
+ shown with its residual t and an accepted factor is named by its id.
257
+ """
258
+ by_id = dict(earlier)
259
+ blocks = []
260
+ for i, (trial, r) in enumerate(trials, 1):
261
+ lines = [
262
+ f"{i}. {trial} [{_direction(r, by_id)}] {r.candidate.get('description', '(unparsed)')}",
263
+ f" hypothesis: {r.candidate.get('hypothesis', '')}",
264
+ f" outcome: {outcome(r)}",
265
+ ]
266
+ blocks.append("\n".join(lines + _statistics(r, earlier)))
267
+ return "\n".join(blocks)
268
+
269
+
270
+ def parent(trial: str, r: TrialRecord, earlier: list[tuple[str, TrialRecord]] = ()) -> str:
271
+ """The trial a research direction builds on, as its proposer reads it: what it computed, its code, statistics and reasons."""
272
+ code = "\n".join(" " + line for line in r.candidate.get("code", "").splitlines())
273
+ lines = [
274
+ f"{trial}, round {r.round}, {r.candidate.get('mechanism_type', '?')}: {r.candidate.get('description', '')}",
275
+ f" hypothesis: {r.candidate.get('hypothesis', '')}",
276
+ f" outcome: {outcome(r)}",
277
+ " code:", code,
278
+ ]
279
+ return "\n".join(lines + _statistics(r, earlier))
280
+
281
+
282
+ def _statistics(r: TrialRecord, earlier: list[tuple[str, TrialRecord]] = ()) -> list[str]:
283
+ """A trial's training statistics, what the pool explains of it, its factor direction and why it was refused, as lines.
284
+
285
+ An accepted factor of ``earlier`` (``named``) is named by its id.
286
+ """
287
+ lines = []
288
+ if r.evaluated:
289
+ m = r.metrics
290
+ nearest = (
291
+ f"nearest existing factor {m['nearest']}, correlation {_num(m['nearest_correlation'], 2)}"
292
+ if m["nearest"] else "no existing factor to compare with"
293
+ )
294
+ if m["nearest_known"]:
295
+ nearest += (
296
+ f"; nearest known factor {m['nearest_known']}, "
297
+ f"sampled correlation {_num(m['nearest_known_correlation'], 2)}"
298
+ )
299
+ lines.append(
300
+ f" training IC {_num(m['ic_mean'], 4)} (Newey-West t {_num(m['ic_t'], 2)}); "
301
+ f"residual IC {_num(m['partial_ic_mean'], 4)} (Newey-West t {_num(m['partial_ic_t'], 2)}); {nearest}"
302
+ )
303
+ overlap = _overlap(m, {accepted_label(e): t for t, e in earlier if e.status == "accepted"})
304
+ if overlap:
305
+ lines.append(overlap)
306
+ order = _decile_order(m.get("decile_returns") or [])
307
+ if order:
308
+ lines.append(
309
+ f" the deciles' returns ranked from 1 (lowest) to 10, lowest factor values first: {order} "
310
+ f"(rising in the factor's direction is 1 to 10, or 10 to 1 for direction -1)"
311
+ )
312
+ lines.append(f" {direction_text(m['ic_mean'])}")
313
+ if r.reasons:
314
+ lines.append(" reasons: " + " | ".join(r.reasons))
315
+ return lines
316
+
317
+
318
+ def accepted(records: list[TrialRecord]) -> str:
319
+ """The accepted factors as the distiller reads them: name, mechanism type, hypothesis and direction, no statistic."""
320
+ return "\n".join(
321
+ f"- {accepted_label(r)} [{r.candidate['mechanism_type']}]: {r.candidate['hypothesis']}; "
322
+ f"{direction_text(r.metrics['ic_mean'])}"
323
+ for r in records if r.status == "accepted"
324
+ )
325
+
326
+
327
+ def best(trials: list[tuple[str, TrialRecord]], contributions: dict[str, Contribution]) -> str:
328
+ """The accepted factors (of ``named`` trials): id, name, mechanism, code, and marginal weight and residual IC in the accepted pool."""
329
+ blocks = []
330
+ for trial, r in trials:
331
+ if r.status != "accepted":
332
+ continue
333
+ label = accepted_label(r)
334
+ part = contributions.get(label)
335
+ code = "\n".join(" " + line for line in r.candidate["code"].splitlines())
336
+ lines = [
337
+ f"- {trial}: {label} [{r.candidate['mechanism_type']}]: {r.candidate['hypothesis']}",
338
+ f" {direction_text(r.metrics['ic_mean'])}", " code:", code,
339
+ ]
340
+ if part is not None:
341
+ lines.append(
342
+ f" in the accepted pool: marginal weight {_num(part.weight, 3)}, residual IC {_num(part.residual_ic, 4)}"
343
+ )
344
+ blocks.append("\n".join(lines))
345
+ return "\n".join(blocks)
346
+
347
+
348
+ def direction_text(ic_mean: float) -> str:
349
+ """The factor direction the training IC shows, in words."""
350
+ sign = direction(ic_mean)
351
+ if sign == 0:
352
+ return "no factor direction (the training IC is zero or not a number)"
353
+ return f"factor direction {sign:+d} (a higher value went with a {'higher' if sign > 0 else 'lower'} return)"
354
+
355
+
356
+ def _decile_order(returns: list[float]) -> str:
357
+ """The rank of each decile's mean return among the deciles, lowest factor values first, or "" when one is missing.
358
+
359
+ The ranks show the shape (rising, U-shaped, broken at one end) that the
360
+ monotonicity check reads, and no return.
361
+ """
362
+ if len(returns) != 10 or not all(isinstance(x, (int, float)) and math.isfinite(x) for x in returns):
363
+ return ""
364
+ order = sorted(range(10), key=lambda i: returns[i])
365
+ return " ".join(str(order.index(i) + 1) for i in range(10))
366
+
367
+
368
+ def _overlap(metrics: dict, ids: dict[str, str]) -> str:
369
+ """What the pool explains of a candidate and the pool factors most correlated with it (accepted ones by ``ids``), or ""."""
370
+ share, overlaps = metrics.get("pool_share"), metrics.get("overlaps") or []
371
+ if share is None or not math.isfinite(share) or not overlaps:
372
+ return ""
373
+ named = ", ".join(f"{ids.get(name, name)} {_num(correlation, 2)}" for name, correlation in overlaps)
374
+ return (
375
+ f" {max(share, 0):.0%} of its daily cross-section is explained by the reference set and the accepted "
376
+ f"factors (the part the residual IC leaves out); most correlated: {named}"
377
+ )
378
+
379
+
380
+ def _direction(r: TrialRecord, by_id: dict[str, TrialRecord] | None = None) -> str:
381
+ """The trial's mechanism type and action, with its parent and, from ``by_id``, the parent's residual t."""
382
+ d = r.direction or {}
383
+ text = f"{d.get('mechanism_type', '?')}, {d.get('action', '?')}"
384
+ if not d.get("parent"):
385
+ return text
386
+ earlier = (by_id or {}).get(d["parent"])
387
+ if earlier is None or not earlier.evaluated:
388
+ return f"{text} of {d['parent']}"
389
+ return f"{text} of {d['parent']} (whose residual t was {_num(earlier.metrics['partial_ic_t'], 2)})"
390
+
391
+
392
+ def outcome(r: TrialRecord) -> str:
393
+ """A trial's outcome in words: accepted, or its status at the stage that decided it."""
394
+ return "accepted" if r.status == "accepted" else f"{r.status} at {r.stage}"
395
+
396
+
397
+
398
+ def _num(value: float, digits: int) -> str:
399
+ return f"{value:+.{digits}f}" if math.isfinite(value) else "n/a"
@@ -0,0 +1,51 @@
1
+ """KunQuant's max drawdown, over a value computed from the fields.
2
+
3
+ KunQuant's ``WindowedMaxDrawdown`` loops over the values of its input without
4
+ putting them in a windowed buffer first: the ``decompose`` pass buffers the
5
+ loop's input, but the loop's ``IterValue`` still reads the input, and
6
+ compiling fails with "Bad buffer" on any input that is not a data column. The
7
+ price-volume fields vwap and amount are computed, so a candidate of
8
+ ``WindowedMaxDrawdown(amount, ...)`` would fail to evaluate. ``patch`` makes the operator loop over a buffer of its
9
+ input, as KunQuant's other windowed operators do; it is the fix proposed to
10
+ KunQuant (https://github.com/Menooker/KunQuant/pull/85), and a KunQuant that
11
+ has it is left as it is.
12
+
13
+ ``_decompose`` is adapted from ``WindowedMaxDrawdown.decompose`` in
14
+ ``KunQuant/ops/CompOp.py`` of KunQuant 0.1.12 (https://github.com/Menooker/KunQuant),
15
+ licensed under the Apache License 2.0; see ``KUNQUANT_LICENSE`` beside this file. The
16
+ one change is that the loop runs over ``WindowedTempOutput(v, window)`` instead of ``v``.
17
+ """
18
+
19
+ from KunQuant.Op import Builder, ConstantOp, ForeachBackWindow, Input, IterValue, WindowedDataSourceOp, WindowedTempOutput, WindowLoopIndex
20
+ from KunQuant.ops import Mul, ReduceMin, Select, TsArgMax, WindowedMax, WindowedMaxDrawdown
21
+
22
+
23
+ def _decompose(self, options: dict) -> list:
24
+ builder = Builder(self.get_parent())
25
+ window = self.attrs["window"]
26
+ v = self.inputs[0]
27
+ with builder:
28
+ peak = WindowedMax(v, window)
29
+ max_bar_index = TsArgMax(v, window) - 1 # TsArgMax starts from 1
30
+ inf = ConstantOp(float("inf"))
31
+ buffered = WindowedTempOutput(v, window)
32
+ with ForeachBackWindow(buffered, window) as each:
33
+ index = WindowLoopIndex(each)
34
+ filtered = Select(index >= max_bar_index, IterValue(each, buffered), inf)
35
+ trough = ReduceMin(filtered)
36
+ (peak - trough) / peak
37
+ return builder.ops
38
+
39
+
40
+ def _loops_over_a_buffer() -> bool:
41
+ """Whether KunQuant's max drawdown of a computed value reads the loop's values from a windowed buffer."""
42
+ with Builder():
43
+ x = Input("x")
44
+ drawdown = WindowedMaxDrawdown(Mul(x, x), 2)
45
+ return all(isinstance(op.inputs[1], WindowedDataSourceOp) for op in drawdown.decompose({}) if isinstance(op, IterValue))
46
+
47
+
48
+ def patch() -> None:
49
+ """Make KunQuant's max drawdown loop over a buffer of its input, unless it already does; calling it again does nothing."""
50
+ if not _loops_over_a_buffer():
51
+ WindowedMaxDrawdown.decompose = _decompose
@@ -0,0 +1,100 @@
1
+ """The trained models' results of one acceptance segment, cached so a re-run trains only what is missing.
2
+
3
+ A model's key is the sha256 of its filled-in component config (the template,
4
+ its features' component configs, the label, the windows and the seed), less
5
+ its save directory and with every path under the run directory relative to
6
+ it (``portable``), so a moved run directory keeps its cache, and of whatever
7
+ else its result depends on (the code version, the TopK size, the scoring).
8
+ The digest is of plain JSON: a value JSON cannot hold raises rather than
9
+ hashing its ``repr``. ``ModelCache`` keeps one JSON file per key under the
10
+ segment's acceptance directory: the result as a plain dict, the seconds its
11
+ training and scoring took and the digest of its template, the record the
12
+ duration estimate reads.
13
+ """
14
+
15
+ import hashlib
16
+ import json
17
+ from pathlib import Path
18
+
19
+
20
+ def key(config: dict, root: Path, **extra) -> str:
21
+ """The cache key of a model of component ``config`` in the run directory ``root``, whose result also depends on ``extra``.
22
+
23
+ Raises
24
+ ------
25
+ TypeError
26
+ If ``config`` or ``extra`` holds a value JSON cannot hold.
27
+ """
28
+ return digest({"model": portable(_without_save_dir(config), root), **extra})
29
+
30
+
31
+ def digest(value) -> str:
32
+ """The sha256 of ``value`` as sorted JSON; a value JSON cannot hold raises ``TypeError``."""
33
+ return hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
34
+
35
+
36
+ def portable(value, root: Path):
37
+ """``value`` with every string path under ``root`` written relative to it, as ``<run_dir>/...``."""
38
+ if isinstance(value, dict):
39
+ return {k: portable(v, root) for k, v in value.items()}
40
+ if isinstance(value, (list, tuple)):
41
+ return [portable(v, root) for v in value]
42
+ if isinstance(value, str):
43
+ for prefix in {str(Path(root)), str(Path(root).resolve())}:
44
+ if value == prefix or value.startswith(prefix + "/"):
45
+ return "<run_dir>" + value[len(prefix):]
46
+ return value
47
+
48
+
49
+ class ModelCache:
50
+ """Cached model results in ``directory``, by ``key``."""
51
+
52
+ def __init__(self, directory: Path):
53
+ self.directory = Path(directory)
54
+
55
+ def get(self, key: str) -> dict | None:
56
+ """The result cached for ``key``, or None."""
57
+ path = self.directory / f"{key}.json"
58
+ return json.loads(path.read_text())["result"] if path.exists() else None
59
+
60
+ def put(self, key: str, result: dict, elapsed: float, template: str) -> None:
61
+ """Cache ``result`` of a model of the template digested ``template``, trained and scored in ``elapsed`` seconds.
62
+
63
+ Written whole or not at all.
64
+ """
65
+ self.directory.mkdir(parents=True, exist_ok=True)
66
+ path = self.directory / f"{key}.json"
67
+ partial = path.with_suffix(".partial")
68
+ partial.write_text(json.dumps({"result": result, "elapsed": elapsed, "template": template}) + "\n")
69
+ partial.replace(path)
70
+
71
+ def summary(self, keys: list[str], template: str) -> str:
72
+ """How many of ``keys`` are to train and cached, and the time to train them.
73
+
74
+ The estimate averages the elapsed times of the finished models of the template digested ``template``.
75
+ """
76
+ missing = sum(not (self.directory / f"{k}.json").exists() for k in keys)
77
+ line = f"{missing} models to train, {len(keys) - missing} cached"
78
+ records = [json.loads(p.read_text()) for p in sorted(self.directory.glob("*.json"))]
79
+ elapsed = [r["elapsed"] for r in records if r["template"] == template]
80
+ if missing and elapsed:
81
+ mean = sum(elapsed) / len(elapsed)
82
+ line += f", about {_duration(missing * mean)} ({_duration(mean)} a model over {len(elapsed)} finished)"
83
+ return line
84
+
85
+
86
+ def _without_save_dir(config):
87
+ """``config`` without any ``model_save_dir``, an ensemble's members' included."""
88
+ if isinstance(config, dict):
89
+ return {k: _without_save_dir(v) for k, v in config.items() if k != "model_save_dir"}
90
+ if isinstance(config, (list, tuple)):
91
+ return [_without_save_dir(v) for v in config]
92
+ return config
93
+
94
+
95
+ def _duration(seconds: float) -> str:
96
+ if seconds < 90:
97
+ return f"{seconds:.0f} s"
98
+ if seconds < 90 * 60:
99
+ return f"{seconds / 60:.0f} min"
100
+ return f"{seconds / 3600:.1f} h"
@@ -0,0 +1,35 @@
1
+ """The round's quota of candidates per mechanism type (after RD-Agent(Q)'s bandit).
2
+
3
+ Every type gets ``min_per_type`` slots. Each of the other slots goes to the
4
+ type with the largest draw from the Beta posterior of its admission rate:
5
+ ``Beta(1 + accepted, 1 + trials - accepted)`` over every earlier trial
6
+ written for a direction of that type, failed ones included, so a type whose
7
+ candidates keep being accepted draws the extra slots. The types are the
8
+ config's, in its order; a record of another type counts toward none. Round n draws from
9
+ the generator seeded ``(seed, n)``: a resumed campaign plans the round as
10
+ the uninterrupted one would.
11
+ """
12
+
13
+ import numpy as np
14
+
15
+ from factor_agent.config import RoundConfig
16
+ from factor_agent.ledger import TrialRecord
17
+
18
+
19
+ def quota(number: int, records: list[TrialRecord], config: RoundConfig, types: tuple[str, ...]) -> dict[str, int]:
20
+ """The slots of each of ``types`` in round ``number``, given the records of the earlier rounds."""
21
+ trials = dict.fromkeys(types, 0)
22
+ accepted = dict.fromkeys(types, 0)
23
+ for record in records:
24
+ kind = record.direction["mechanism_type"]
25
+ if kind not in trials:
26
+ continue
27
+ trials[kind] += 1
28
+ accepted[kind] += record.status == "accepted"
29
+ slots = dict.fromkeys(types, config.min_per_type)
30
+ rng = np.random.default_rng([config.seed, number])
31
+ alpha = np.array([1 + accepted[kind] for kind in types])
32
+ beta = np.array([1 + trials[kind] - accepted[kind] for kind in types])
33
+ for _ in range(config.candidates - config.min_per_type * len(types)):
34
+ slots[types[int(np.argmax(rng.beta(alpha, beta)))]] += 1
35
+ return slots