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.
- factor_agent/KUNQUANT_LICENSE +201 -0
- factor_agent/__init__.py +8 -0
- factor_agent/_acceptance_model.py +93 -0
- factor_agent/_admission.py +168 -0
- factor_agent/_expression_fold.py +48 -0
- factor_agent/_feedback.py +399 -0
- factor_agent/_max_drawdown.py +51 -0
- factor_agent/_model_cache.py +100 -0
- factor_agent/_scheduler.py +35 -0
- factor_agent/acceptance.py +808 -0
- factor_agent/accepted/__init__.py +1 -0
- factor_agent/aggregations.py +122 -0
- factor_agent/campaign.py +797 -0
- factor_agent/candidate.py +51 -0
- factor_agent/config.py +651 -0
- factor_agent/derived.py +98 -0
- factor_agent/evaluator.py +1096 -0
- factor_agent/events.py +343 -0
- factor_agent/exported.py +177 -0
- factor_agent/gate/__init__.py +359 -0
- factor_agent/gate/_canonical.py +331 -0
- factor_agent/gate/_checks.py +85 -0
- factor_agent/gate/_constraints.py +85 -0
- factor_agent/gate/_dimension.py +177 -0
- factor_agent/gate/_originality.py +146 -0
- factor_agent/gate/_sandbox.py +105 -0
- factor_agent/gate/graph.py +223 -0
- factor_agent/gate/stages.py +383 -0
- factor_agent/gate/vocabulary.py +1097 -0
- factor_agent/ledger.py +85 -0
- factor_agent/llm/__init__.py +0 -0
- factor_agent/llm/_common.py +18 -0
- factor_agent/llm/_raw.py +78 -0
- factor_agent/llm/anthropic.py +100 -0
- factor_agent/llm/base.py +124 -0
- factor_agent/llm/claude_code.py +101 -0
- factor_agent/llm/log.py +74 -0
- factor_agent/llm/openai_compatible.py +113 -0
- factor_agent/llm/replay.py +59 -0
- factor_agent/llm/scripted.py +39 -0
- factor_agent/period_lag.py +91 -0
- factor_agent/prompts.py +557 -0
- factor_agent/researcher.py +615 -0
- factor_agent/scoring.py +143 -0
- factor_agent/segments.py +184 -0
- factor_agent/session.py +500 -0
- factor_agent/snapshot.py +219 -0
- factor_agent/web/__init__.py +7 -0
- factor_agent/web/_acceptance.py +145 -0
- factor_agent/web/_config.py +51 -0
- factor_agent/web/_diagnosis.py +110 -0
- factor_agent/web/_files.py +78 -0
- factor_agent/web/_health.py +207 -0
- factor_agent/web/_ic.py +27 -0
- factor_agent/web/_ledger.py +293 -0
- factor_agent/web/_memory.py +42 -0
- factor_agent/web/_results.py +77 -0
- factor_agent/web/_round.py +219 -0
- factor_agent/web/_trials.py +112 -0
- factor_agent/web/app.py +118 -0
- factor_agent/web/run_view.py +329 -0
- factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
- factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
- factor_agent/web/static/index.html +13 -0
- factor_agent/zoo.py +136 -0
- quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
- quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
- quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
- quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
- 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)
|