Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 37 additions & 4 deletions docs/full_integration_test_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ provider 已识别、key env 已继承但真实 key 未打印、各数据/因子
摘要(screenshots / page-state)作为真实前端操作证据。

**当前 IA:7 个顶层页签(tabs)** —— 「LLM 因子工作台」(内含两个模块:单因子研究 /
多因子策略回测)·「研究历史」·「数据」·「注册表」·「文档」·「扩展」·「记忆治理」。
多因子策略回测)·「研究历史」·「数据」·「已注册因子」·「文档」·「扩展」·「记忆治理」。
顶层 tab id 依次为 `lab-tab-factor` / `lab-tab-history` / `lab-tab-data` /
`lab-tab-registry` / `lab-tab-docs` / `lab-tab-extensions` / `lab-tab-memory`;
工作台内两个模块 id 为 `lab-module-single`(单因子研究)与
Expand All @@ -158,6 +158,28 @@ provider 已识别、key env 已继承但真实 key 未打印、各数据/因子
minimax/openai_compatible)可在此注册启用,缺省 model 的预设需补填模型名。
- 控制令牌门控 UX(token-gating):需要令牌时页面提示明确,粘贴/预置后功能解锁。

#### 4.1.1 运行时切换大模型 / runtime LLM switch(NEW,`POST /api/settings/llm`)

网页端可在**不改 YAML、不重启**的前提下注册/切换 provider 并输入密钥。作为验收步骤逐项验证:

- **预设可见性:** `#llm-provider` 下拉除已配置 provider 外,还应列出 6 个内置预设
(`deepseek` / `openai` / `openai_compatible` / `glm` / `claude` / `minimax`);未在 YAML
配过的预设标注**「预设未启用」**(`configured=false`)。
- **注册预设 + 注入密钥:** 选一个未启用预设 → `#llm-api-key-mode` 切「前端输入(注入本次
运行)」→ 填一个 **dummy 密钥**(缺省 model 的预设如 openai/glm/claude 会多出模型名输入框
`#llm-model`;`openai_compatible` 还多出 `#llm-base-url`,可填本地 `http://127.0.0.1:11434/v1`)
→ 点「保存并启用」(`#llm-settings-save`)。**期望:** 状态提示「已保存…」、`#llm-api-key`
立即清空、运行时状态条切成该 provider、该预设的 `runtime_ready` 翻成 `true`。
- **密钥保密(HONESTY / P4):** dummy 密钥值**绝不**出现在任何响应体、`/api/status`、页面
DOM、日志或工作区磁盘;`#llm-api-key` 属性为 `data-secret-policy="runtime-memory-only"`;
保存后重启容器则失效(进程内存语义)。
- **base_url 重定向护栏(SECURITY):** 对一个 **env 里已有常驻密钥**的 provider(如 deepseek)
只改 `base_url`、不重填密钥 → 期望 **400**(错误含「re-supplying api_key」),且原 base_url
未被改写。带上密钥再改则允许。此举防止把宿主常驻密钥发往新指定地址。
- **切回不回归:** 切回原配置 provider(`{"provider":"deepseek"}`,不带密钥)应成功、
`key_updated=false`、真实 DeepSeek 链路 readiness 仍为 `ready`。
- **令牌门控一致:** Docker/`0.0.0.0` 绑定下,无令牌 `POST /api/settings/llm` 返回 `401`。

### 4.2 单因子研究 / single-factor module(真实 DeepSeek)

切到「单因子研究」模块。用三个 seed(**逐字**,见附录 C)各跑一遍。
Expand Down Expand Up @@ -329,7 +351,9 @@ seed)、`last_accepted_factor_id`、`last_explored_factor_id`、

- 「数据」(`#lab-panel-data`):字段目录(catalog)、覆盖范围(coverage)、质量门
(quality gate)。
- 「注册表」(`#lab-panel-registry`):因子列表 + 详情 + 证据链(evidence chain)。
- 「已注册因子」(`#lab-panel-registry`):因子列表 + 详情 + 证据链(evidence chain)。
标签文案是**「已注册因子」**(不再是旧的「注册表」);内部 id 仍是 `lab-tab-registry` /
`lab-panel-registry`(深链与路由不变)。
- 「文档」(`#lab-panel-docs`):docs 目录 + 渲染后的 markdown(应能看到本文件与
`frontend_contributing.md`)。
- 「扩展」(`#lab-panel-extensions`):扩展 manifest 清单 + 校验状态(validation status)。
Expand Down Expand Up @@ -374,6 +398,15 @@ seed)、`last_accepted_factor_id`、`last_explored_factor_id`、
`/api` 路径 → 返回 index shell(深链与拼写错误都落到首页外壳)。
- **payload 卫生(hygiene):** 任何 payload 都**不得含绝对路径**;尤其
`GET /api/data/status` 不得泄漏 `data_root` / `panel_path`(artifact 字段应为 basename)。
- **LLM 运行时设置端点(NEW,`POST /api/settings/llm`):**
- `GET /api/status` 的 `llm.providers[]` 除已配置项外还带内置预设,每项含
`configured` / `runtime_ready` / `api_key_env`(**只有环境变量名,无 key 值**)。
- `POST` 一个预设 + dummy `api_key` → `200`,响应含 `key_updated:true`,但**响应体、
`/api/status`、任何日志/磁盘都不含 key 值**;该预设 `runtime_ready` 翻 `true`。
- **护栏 400:** 对已有常驻密钥的 provider 只改 `base_url`、不带 `api_key` → `400`
(错误含 `re-supplying api_key`),base_url 不变。
- **门控:** 无令牌 `POST /api/settings/llm` → `401`。非法输入(未知 provider / 含空白的
key / 非 http(s) 的 base_url / 预设缺 model / `provider:"rule"`)→ 同步 `400`。
- **合成端点形状:**
- `GET /api/synthesis/methods` 返回 `{methods, standardizations}`:4 个方法
(`equal_weight` / `weighted` 先验 + `ic_weighted` / `icir_weighted` 拟合,P6 终态全部
Expand Down Expand Up @@ -524,7 +557,7 @@ doctor 必须 DeepSeek runtime-ready;llm-smoke 必须完成一次真实解析

阶段 3 真实前端联调(按能力阶梯取最高级:L1 真实桌面 Chrome,L2 真实 Chrome 程序化,
L3 API 仅补充非验收):走 4.0–4.5——6-tab + 两模块 IA;provider 与 key 控件;单因子
三个 seed(附录 C,逐字)解析→评测→RD;多因子模块合成→回测;只读面(数据/注册表/文档/
三个 seed(附录 C,逐字)解析→评测→RD;多因子模块合成→回测;只读面(数据/已注册因子/文档/
扩展);横切(深链 reload、console 零错误、暗色 + 375px via CDP device metrics)。保留截图/
页面状态证据。禁止用 /api/jobs/* 替代点击。

Expand Down Expand Up @@ -576,7 +609,7 @@ L3 API 仅补充非验收):走 4.0–4.5——6-tab + 两模块 IA;provide

**一个多因子合成配方 / a multi-factor recipe(用于 4.3):**

- 从注册表/已产出的因子里挑 2–3 个(例如上面三个 seed 验证后注册的因子)。
- 从「已注册因子」/已产出的因子里挑 2–3 个(例如上面三个 seed 验证后注册的因子)。
- 方法选 `weighted`,为每个因子填一个权重,例如 `0.5 / 0.3 / 0.2`(方向按定义 `+1`,
除非要显式反向 `-1`);标准化选 `zscore`。
- `holding_days = 5`(必填);其余留空由后端 profile 默认值决定。
Expand Down
12 changes: 11 additions & 1 deletion docs/integration_workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,9 @@ API/HTTP 调用只允许核对后端 contract、artifact 路径、日期窗口

1. 打开桌面 Chrome,访问本地 Web URL。
2. 检查左侧 runtime strip 是否展示当前 `LLM`、`data`、`factors`、`values`、`overlay`、`artifacts`。
3. 在 `LLM Provider` 中选择 DeepSeek 或当前配置的云端 provider。
3. 在 `LLM Provider` 中选择 DeepSeek 或当前配置的云端 provider。下拉除已配置 provider 外,
还应列出内置预设(deepseek/openai/openai_compatible/glm/claude/minimax),未在 YAML 配过的
标注“预设未启用”。
4. 检查 `LLM API Key` 控件:
- 默认模式应为“配置文件 / 环境变量加载”。
- 如果 provider key 已通过配置和环境变量加载,password 输入框必须置灰。
Expand All @@ -339,6 +341,14 @@ API/HTTP 调用只允许核对后端 contract、artifact 路径、日期窗口
- 密钥仅存在于运行中进程的内存,不写入磁盘、配置、日志、artifact 或报告,任何响应
(含 `/api/status`)都不得回显密钥值;重启后失效,持久化仍走 `configs/*.local.env`。
- 手动输入的密钥不得进入 parse/validate 请求体;正式调用仍从后端环境变量读取。
4b. 运行时切换大模型(NEW)联调:
- 选一个“预设未启用”的 provider → 切“前端输入” → 填 dummy 密钥(缺省 model 的预设会多出
模型名输入框;openai_compatible 还多出 base_url 框,可填本地 `http://127.0.0.1:11434/v1`)
→ 点“保存并启用”。期望:状态提示“已保存…”、密钥框清空、runtime strip 切成该 provider、
该预设 `runtime_ready` 翻 true。全程 dummy 密钥不得出现在任何响应/DOM/日志/磁盘。
- base_url 护栏:对一个 env 已有常驻密钥的 provider 只改 base_url、不重填密钥 → 期望 400
(错误含 “re-supplying api_key”),原 base_url 不变;带密钥再改则允许。
- 切回原配置 provider(不带密钥)应成功、`key_updated=false`、真实链路 readiness 仍 ready。
5. 输入自然语言因子观点,点击“解析因子”。
6. 解析完成后应只展示 factor 草稿和待确认参数,不应立即执行评价/回测。
7. 检查评测参数区是否自动填入默认值:
Expand Down
17 changes: 15 additions & 2 deletions src/quant_forge/llm_factor_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
# Reuse it here instead of defining a parallel copy so the web parse path and
# the spec flow can never drift apart.
from quant_forge.specs.nl_flow import ( # noqa: E402 (grouped with the contract note above)
out_of_scope_data_warnings as out_of_scope_data_warnings,
_FALLBACK_WARNING as GENERIC_FALLBACK_WARNING,
_GENERIC_FALLBACK_FORMULA as GENERIC_FALLBACK_FORMULA,
)
Expand Down Expand Up @@ -60,6 +61,18 @@ class ParsedFactor:
warnings: tuple[str, ...] = ()


def parse_warnings(formula: str, text: str) -> tuple[str, ...]:
"""Every honest-parse warning for a result: the generic-fallback flag plus
the out-of-scope-data flag, deduplicated and order-preserving. Both parse
modes assemble their ``warnings`` through this single helper."""

combined: list[str] = []
for warning in (*generic_fallback_warnings(formula), *out_of_scope_data_warnings(text)):
if warning not in combined:
combined.append(warning)
return tuple(combined)


def generic_fallback_warnings(formula: str) -> tuple[str, ...]:
"""Warnings a parse result must carry when it landed on the generic formula.

Expand All @@ -84,7 +97,7 @@ def parse_factor_idea(text: str, llm: LLMSettings, *, mode: str = "llm") -> Pars
source="rule",
provider="rule",
model="deterministic",
warnings=generic_fallback_warnings(factor.formula),
warnings=parse_warnings(factor.formula, text),
)
if mode != "llm":
raise ValueError(f"unsupported parser mode: {mode}")
Expand All @@ -103,7 +116,7 @@ def _parse_with_configured_llm(text: str, llm: LLMSettings) -> ParsedFactor:
provider=result.provider,
model=result.model,
raw_response=result.content,
warnings=generic_fallback_warnings(factor.formula),
warnings=parse_warnings(factor.formula, text),
)


Expand Down
55 changes: 55 additions & 0 deletions src/quant_forge/specs/nl_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,58 @@
"not have understood the idea - review before running"
)

# No-silent-fallback, extended past the rank(close) catch-all. The public
# workbench panel has only price-volume fields (close / is_st / market_cap /
# return_* / volatility_* / volume). When an idea references a data domain the
# panel cannot express -- financial statements, valuation multiples, alt-data --
# the parser (LLM or rule) can only degrade to a price-volume formula, so it may
# silently return e.g. rank(return_5d) for a fundamentals idea and look
# confident. Detect the domain mismatch from the idea text and attach a review
# warning so an approximation is never presented as a faithful implementation.
#
# This is a curated, deliberately conservative heuristic (recall over a fixed
# set, not exhaustive): terms are chosen to avoid colliding with in-scope
# price-volume vocabulary (收益/回报/动量/波动/成交量/市值/价格) and with the
# built-in demo seeds (notably the bare word 估值 is intentionally NOT listed,
# so the "低估值" seed keeps its existing rank(close) catch-all behavior).
_OUT_OF_SCOPE_DATA_TERMS: tuple[str, ...] = (
# fundamentals / financial statements (Chinese)
"基本面", "利润", "净利", "毛利", "营收", "营业收入", "财报", "年报", "季报",
"中报", "业绩", "每股收益", "净资产", "资产负债", "现金流", "负债", "商誉",
"分红", "股息", "派息", "应收", "存货", "扣非",
# valuation multiples that need fundamentals (the bare 估值/价值 is excluded)
"市盈", "市净", "市销", "股息率",
# alternative data (Chinese)
"舆情", "研报", "分析师", "北向", "龙虎榜", "机构持仓", "增减持", "股东户数",
# fundamentals / valuation / alt-data (English, multi-char to avoid matches
# inside unrelated words like "approach" / "broad")
"earnings", "revenue", "profit", "dividend", "cash flow", "cashflow",
"book value", "ebitda", "fundamental", "p/e", "p/b", "sentiment", "analyst",
)
_OUT_OF_SCOPE_DATA_WARNING = (
"本数据集只有量价字段(close/market_cap/return/volatility/volume 等),"
"无法表达该想法涉及的基本面/估值/另类数据,公式只是最接近的量价近似,"
"并非该想法的真实实现,请复核 / this idea references data not in the "
"price-volume dataset; the formula is only a nearest price-volume "
"approximation, not a faithful implementation - review before running"
)


def out_of_scope_data_warnings(text: str) -> tuple[str, ...]:
"""Warn when an idea references a data domain the price-volume panel lacks.

Returns a single review warning (or empty) so both parse modes can attach
it: an idea about profit growth, valuation multiples, or alt-data can only
degrade to a price-volume formula here, and that degradation must not be
presented as a confident match.
"""

lowered = text.lower()
for term in _OUT_OF_SCOPE_DATA_TERMS:
if term in lowered:
return (_OUT_OF_SCOPE_DATA_WARNING,)
return ()


def factor_spec_from_idea(text: str) -> tuple[FactorSpec, SpecValidationResult]:
"""Deterministically draft a FactorSpec from a beginner idea and gate it.
Expand All @@ -49,6 +101,9 @@ def factor_spec_from_idea(text: str) -> tuple[FactorSpec, SpecValidationResult]:
result = validate_factor_spec(spec)
if definition.formula == _GENERIC_FALLBACK_FORMULA:
result = replace(result, warnings=result.warnings + (_FALLBACK_WARNING,))
extra = out_of_scope_data_warnings(text)
if extra:
result = replace(result, warnings=result.warnings + extra)
return spec, result


Expand Down
81 changes: 81 additions & 0 deletions tests/test_llm_factor_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,87 @@ def test_llm_mode_generic_formula_carries_identical_fallback_warning(monkeypatch
assert parsed.warnings == (GENERIC_FALLBACK_WARNING,)


class FakeMomentumHandler(BaseHTTPRequestHandler):
"""Mimics the real degradation: an unmappable fundamentals idea comes back
as a confident-looking momentum formula (the workbench prompt only allows a
handful of price-volume formulas, so the model picks the nearest one)."""

def do_POST(self) -> None:
length = int(self.headers["Content-Length"])
json.loads(self.rfile.read(length).decode("utf-8"))
content = {
"name": "profit_growth_momentum",
"formula": "rank(return_5d)",
"description": "年报、季报利润同比增加高的公司,在未来21天的表现会更好",
"horizon_days": 21,
"universe_filters": [],
}
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"choices": [{"message": {"content": json.dumps(content)}}]}).encode("utf-8"))

def log_message(self, format: str, *args: object) -> None:
return


def test_out_of_scope_data_warns_on_fundamentals(monkeypatch: pytest.MonkeyPatch) -> None:
# The exact reported case: a fundamentals idea the price-volume panel cannot
# express degrades to rank(return_5d) but must NOT look confident -- the
# parse carries an out-of-scope review warning even though the formula is a
# valid non-fallback (rank(return_5d), not rank(close)).
server = ThreadingHTTPServer(("127.0.0.1", 0), FakeMomentumHandler)
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
monkeypatch.setenv("QF_TEST_DEEPSEEK_KEY", "test-key")
try:
parsed = parse_factor_idea(
"年报、季报利润同比增加高的公司,在未来21天的表现会更好",
LLMSettings(
provider="deepseek",
model="fake-deepseek",
base_url=f"http://127.0.0.1:{server.server_port}",
api_key_env="QF_TEST_DEEPSEEK_KEY",
),
)
finally:
server.shutdown()
thread.join(timeout=5)

assert parsed.factor.formula == "rank(return_5d)"
assert parsed.warnings, "fundamentals idea must carry a review warning"
assert any("基本面" in w or "price-volume" in w for w in parsed.warnings)
# rank(return_5d) is NOT the rank(close) catch-all, so the generic-fallback
# warning must not be what fired here.
assert GENERIC_FALLBACK_WARNING not in parsed.warnings


def test_out_of_scope_detector_recall_and_no_false_positives() -> None:
from quant_forge.specs.nl_flow import out_of_scope_data_warnings

# Out-of-scope data domains warn.
for idea in (
"年报、季报利润同比增加高的公司未来更好",
"现金流充裕、负债率低的公司",
"市盈率低、股息率高的股票",
"stocks with rising earnings and revenue",
"high analyst sentiment names",
):
assert out_of_scope_data_warnings(idea), idea

# In-scope price-volume ideas and the built-in demo seeds do NOT warn, and
# short English terms never match inside unrelated words (approach/broad).
for idea in (
"非ST的小市值股票未来表现更好",
"过去5天涨幅较大的股票,短期动量继续",
"低波动率的股票更稳",
"成交量放大的股票",
"估值越低的股票,长期收益越好", # bare 估值 demo seed: keeps rank(close) path
"approach the broad market with high volume",
):
assert not out_of_scope_data_warnings(idea), idea


def test_llm_mode_specific_formula_carries_no_fallback_warning(monkeypatch: pytest.MonkeyPatch) -> None:
server = ThreadingHTTPServer(("127.0.0.1", 0), FakeLLMHandler)
thread = Thread(target=server.serve_forever, daemon=True)
Expand Down
Loading