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,113 @@
1
+ """The OpenAI-compatible backend: chat completions at any ``base_url`` (OpenAI, DeepSeek, DashScope,
2
+ Gemini's compatible endpoint, a local vLLM or Ollama), the key from the environment variable the config names."""
3
+
4
+ import openai
5
+
6
+ from factor_agent.llm._common import key_from_environment
7
+ from factor_agent.llm.base import ProviderQuotaExhausted, ProviderReplyMalformed, Reply, Request
8
+
9
+
10
+ class OpenAICompatibleBackend:
11
+ """Complete a request with one chat-completions call.
12
+
13
+ Parameters
14
+ ----------
15
+ model : str
16
+ The model of every call.
17
+ base_url : str
18
+ The provider's endpoint, such as ``https://api.deepseek.com/v1``.
19
+ api_key_env : str, optional
20
+ The environment variable holding the key; None for a local server without one.
21
+ max_tokens : int, optional
22
+ The ``max_tokens`` of every call; the provider's default if None.
23
+ max_completion_tokens : int, optional
24
+ The ``max_completion_tokens`` of every call, in place of
25
+ ``max_tokens``: OpenAI's reasoning models take only this one, and
26
+ count the reasoning tokens in it.
27
+ reasoning_effort : str, optional
28
+ The ``reasoning_effort`` of every call (such as ``"low"``,
29
+ ``"medium"``, ``"high"``), for a reasoning model; the provider's
30
+ default if None.
31
+ timeout_seconds : float
32
+ Seconds a call may take.
33
+
34
+ Raises
35
+ ------
36
+ ValueError
37
+ If both ``max_tokens`` and ``max_completion_tokens`` are given.
38
+ """
39
+
40
+ def __init__(
41
+ self, model: str, base_url: str, api_key_env: str | None = None,
42
+ max_tokens: int | None = None, max_completion_tokens: int | None = None,
43
+ reasoning_effort: str | None = None, timeout_seconds: float = 600.0,
44
+ ):
45
+ if max_tokens is not None and max_completion_tokens is not None:
46
+ raise ValueError("openai_compatible: give max_tokens or max_completion_tokens, not both")
47
+ self.model = model
48
+ self.max_tokens = max_tokens
49
+ self.max_completion_tokens = max_completion_tokens
50
+ self.reasoning_effort = reasoning_effort
51
+ key = key_from_environment(api_key_env) if api_key_env else "none"
52
+ self._client = openai.OpenAI(api_key=key, base_url=base_url, timeout=timeout_seconds)
53
+
54
+ @classmethod
55
+ def from_config(cls, spec: dict) -> "OpenAICompatibleBackend":
56
+ """Build from a role's config: ``model``, ``base_url`` and optionally ``api_key_env``,
57
+ ``max_tokens`` or ``max_completion_tokens``, ``reasoning_effort`` and ``timeout_seconds``."""
58
+ names = ("api_key_env", "max_tokens", "max_completion_tokens", "reasoning_effort", "timeout_seconds")
59
+ options = {k: spec[k] for k in names if k in spec}
60
+ return cls(spec["model"], spec["base_url"], **options)
61
+
62
+ @property
63
+ def params(self) -> dict:
64
+ """The parameters every call sends with the prompt."""
65
+ params = {
66
+ "max_tokens": self.max_tokens, "max_completion_tokens": self.max_completion_tokens,
67
+ "reasoning_effort": self.reasoning_effort,
68
+ }
69
+ return {k: v for k, v in params.items() if v is not None}
70
+
71
+ def complete(self, request: Request) -> Reply:
72
+ """Send ``request``'s prompt as one user message.
73
+
74
+ Raises
75
+ ------
76
+ ProviderQuotaExhausted
77
+ If the provider answers HTTP 402 (Payment Required): the balance or quota is exhausted.
78
+ openai.APIError
79
+ If the call fails otherwise, after the SDK's retries (which never retry a 402).
80
+ ProviderReplyMalformed
81
+ If the provider answers something that is not a reply: no body
82
+ (``null``), no choice, no message or no token usage.
83
+ """
84
+ try:
85
+ response = self._client.chat.completions.create(
86
+ model=self.model, messages=[{"role": "user", "content": request.prompt}], **self.params,
87
+ )
88
+ except openai.APIStatusError as exc:
89
+ if exc.status_code == 402:
90
+ raise ProviderQuotaExhausted(f"openai_compatible: {self.model}: {exc.message}") from exc
91
+ raise
92
+ lacking = _lacking(response)
93
+ if lacking:
94
+ raise ProviderReplyMalformed(f"openai_compatible: {self.model} answered a reply without {lacking}")
95
+ usage = response.usage
96
+ return Reply(
97
+ text=response.choices[0].message.content or "",
98
+ usage={"input_tokens": usage.prompt_tokens, "output_tokens": usage.completion_tokens},
99
+ model_id=response.model, params=self.params, raw=response.to_json(indent=None),
100
+ )
101
+
102
+
103
+ def _lacking(response) -> str | None:
104
+ """What ``response`` lacks to be a reply (its body, a choice, the message, the usage), or None."""
105
+ if response is None:
106
+ return "a body"
107
+ if not getattr(response, "choices", None):
108
+ return "a choice"
109
+ if getattr(response.choices[0], "message", None) is None:
110
+ return "a message"
111
+ if getattr(response, "usage", None) is None:
112
+ return "token usage"
113
+ return None
@@ -0,0 +1,59 @@
1
+ """The replay backend: a recorded run's replies served back, so a campaign reproduces without a provider."""
2
+
3
+ from collections import defaultdict, deque
4
+
5
+ from factor_agent.llm.base import Reply, Request
6
+ from factor_agent.llm.log import LLMLog
7
+
8
+
9
+ class ReplayExhausted(RuntimeError):
10
+ """The recorded run holds no reply for a request: it ran out of calls, or the run asked something else."""
11
+
12
+
13
+ class ReplayBackend:
14
+ """Answer each request with the recorded reply to the same role, model and prompt.
15
+
16
+ A request asked several times gets its recorded replies in order.
17
+
18
+ Raises
19
+ ------
20
+ ValueError
21
+ If the log holds no call (a wrong path, or a run that made none).
22
+ """
23
+
24
+ def __init__(self, log: LLMLog):
25
+ self.log = log
26
+ self._replies: dict[tuple[str, str, str], deque] = defaultdict(deque)
27
+ calls = log.calls()
28
+ if not calls:
29
+ raise ValueError(f"replay: no recorded call in {log.path}")
30
+ for call in calls:
31
+ request = call["request"]
32
+ self._replies[request["role"], request["model"], request["prompt"]].append(call)
33
+
34
+ @classmethod
35
+ def from_config(cls, spec: dict) -> "ReplayBackend":
36
+ """Build from ``{"log": <the llm_log directory of the recorded run>}``."""
37
+ return cls(LLMLog(spec["log"]))
38
+
39
+ def complete(self, request: Request) -> Reply:
40
+ """Return the recorded reply to ``request``.
41
+
42
+ Raises
43
+ ------
44
+ ReplayExhausted
45
+ If the log holds no reply left for this role, model and prompt.
46
+ """
47
+ queue = self._replies.get((request.role, request.model, request.prompt))
48
+ if not queue:
49
+ raise ReplayExhausted(
50
+ f"replay of {self.log.path}: no recorded reply left for this {request.role} request "
51
+ f"(the recorded run ran out of calls, or this run asked something it never did); "
52
+ f"the prompt begins {request.prompt[:200]!r}"
53
+ )
54
+ call = queue.popleft()
55
+ reply = call["reply"]
56
+ return Reply(
57
+ text=reply["text"], usage=reply["usage"], model_id=reply["model_id"],
58
+ params=call["request"]["params"], raw=reply["raw"],
59
+ )
@@ -0,0 +1,39 @@
1
+ """A scripted backend: replies read from a file, in order. For tests and offline smoke runs."""
2
+
3
+ import json
4
+ import threading
5
+ from pathlib import Path
6
+
7
+ from factor_agent.llm.base import Reply, Request
8
+
9
+
10
+ class ScriptedBackend:
11
+ """Answer each request with the next reply of a fixed list.
12
+
13
+ ``requests`` keeps every request received, in order; calls from several
14
+ threads take the replies one at a time, in the order they arrive.
15
+
16
+ Raises
17
+ ------
18
+ RuntimeError
19
+ From ``complete`` when every reply has been used.
20
+ """
21
+
22
+ def __init__(self, replies: list[str]):
23
+ self._replies = list(replies)
24
+ self.requests: list[Request] = []
25
+ self._taking = threading.Lock()
26
+
27
+ @classmethod
28
+ def from_config(cls, spec: dict) -> "ScriptedBackend":
29
+ """Build from ``{"replies": <path to a JSON list of reply texts>}``."""
30
+ return cls(json.loads(Path(spec["replies"]).read_text()))
31
+
32
+ def complete(self, request: Request) -> Reply:
33
+ """Return the next scripted reply and keep ``request``."""
34
+ with self._taking:
35
+ if len(self.requests) >= len(self._replies):
36
+ raise RuntimeError(f"ScriptedBackend: no reply left for request {len(self.requests) + 1}")
37
+ text = self._replies[len(self.requests)]
38
+ self.requests.append(request)
39
+ return Reply(text=text, usage={}, model_id="scripted")
@@ -0,0 +1,91 @@
1
+ """Period lags read from data: the lag columns ``PeriodLag(x, k)`` reads, computed when a factor is.
2
+
3
+ A lag column (``Vocabulary.lag_columns``) is no column of the price
4
+ dataset but a derived column (``factor_agent.derived``): its provider,
5
+ ``PeriodLags``, reads the field's column and the key column of its family
6
+ (the period end of the report each bar shows) and computes it with
7
+ quantlab's ``report_lags``, as ``FundamentalAlpha`` computes its report
8
+ lags. On a bar showing report r, the value ``k`` periods ago is the last
9
+ value shown during the k-th earlier report shown; it is missing unless that
10
+ report's period ends ``k * days`` (within ``tolerance_days``) before the
11
+ current one, so a skipped or missing report never passes for an earlier
12
+ period. A key that is a date counts in days; a numeric key is used as it
13
+ is.
14
+
15
+ A lag of ``k`` periods reads ``(k + 2) * bars`` bars before the first bar it
16
+ computes: the k earlier periods, the current one and one more for a late
17
+ filing.
18
+
19
+ Each lag column is described by a mapping, as ``Vocabulary.lag_columns``
20
+ gives it: ``column`` (the field's dataset column), ``k``, ``key``,
21
+ ``days``, ``tolerance_days`` and ``bars``.
22
+ """
23
+
24
+ from collections.abc import Iterable, Mapping
25
+
26
+ import numpy as np
27
+ import xarray as xr
28
+ from quantlab.dataset.base import BaseDataset
29
+ from quantlab.factor.predefined.fundamental_alpha import report_lags
30
+
31
+ from factor_agent import derived
32
+
33
+
34
+ def read(inputs: Iterable[str], lag_columns: Mapping[str, Mapping]) -> dict[str, dict]:
35
+ """The entries of ``lag_columns`` (``Vocabulary.lag_columns``) for the columns ``inputs``, by sorted name."""
36
+ return {name: dict(lag_columns[name]) for name in sorted(inputs) if name in lag_columns}
37
+
38
+
39
+ def warmup_bars(lags: Mapping[str, Mapping]) -> int:
40
+ """The bars before the first computed bar the ``lags`` read; 0 without any."""
41
+ return max(((int(lag["k"]) + 2) * int(lag["bars"]) for lag in lags.values()), default=0)
42
+
43
+
44
+ class PeriodLags(derived.Provider):
45
+ """The lag columns ``lags`` (``read``), filled from the daily panel; see the module docstring.
46
+
47
+ It reads the fields' columns and keys (``data_columns``), the keys from
48
+ the panel only, and the warm-up the lags need (``warmup_bars``).
49
+ """
50
+
51
+ def __init__(self, lags: Mapping[str, Mapping]):
52
+ self._lags = {name: dict(lag) for name, lag in lags.items()}
53
+ self.columns = tuple(self._lags)
54
+ self.data_columns = tuple(sorted({c for lag in self._lags.values() for c in (lag["column"], lag["key"])}))
55
+ self.panel_columns = tuple(sorted({lag["key"] for lag in self._lags.values()}))
56
+ self.warmup_bars = warmup_bars(self._lags)
57
+
58
+ def fill(
59
+ self, dataset: BaseDataset, panel: xr.Dataset, arrays: dict[str, np.ndarray],
60
+ symbols: np.ndarray, timestamps: np.ndarray,
61
+ ) -> dict[str, np.ndarray]:
62
+ groups: dict[tuple, dict[str, list]] = {}
63
+ for name, lag in self._lags.items():
64
+ group = groups.setdefault((lag["key"], float(lag["days"]), float(lag["tolerance_days"])), {})
65
+ group.setdefault(lag["column"], []).append((int(lag["k"]), name))
66
+ lagged = {}
67
+ for (key, days, tolerance), wanted in groups.items():
68
+ periods = _key_values(panel[key].sel(timestamp=timestamps, symbol=symbols).transpose("timestamp", "symbol"))
69
+ out = report_lags(
70
+ {column: arrays[column] for column in wanted}, periods,
71
+ {column: tuple(k for k, _ in ks) for column, ks in wanted.items()}, days, tolerance,
72
+ )
73
+ for column, ks in wanted.items():
74
+ for k, name in ks:
75
+ lagged[name] = out[f"{column}__q{k}"]
76
+ return lagged
77
+
78
+
79
+ def providers(lags: Mapping[str, Mapping]) -> list[derived.Provider]:
80
+ """The provider of the lag columns ``lags``, in a list; none without any."""
81
+ return [PeriodLags(lags)] if lags else []
82
+
83
+
84
+ def _key_values(key: xr.DataArray) -> np.ndarray:
85
+ """The key as float: days since 1970 for dates (NaN where NaT), the numbers themselves otherwise."""
86
+ values = key.to_numpy()
87
+ if np.issubdtype(values.dtype, np.datetime64):
88
+ days = values.astype("datetime64[D]").astype(np.float64)
89
+ days[np.isnat(values)] = np.nan
90
+ return days
91
+ return values.astype(np.float64)