feat: 分离 Alpha 检查与提交限制,优化检查统计与前端展示

This commit is contained in:
yuxuanhui
2026-09-12 22:11:11 +08:00
parent 5eb6008ef7
commit 45eb4c3a17
13 changed files with 300 additions and 21 deletions
@@ -0,0 +1,11 @@
# 分离 Alpha 检查和提交限制
Type: task
Status: ready-for-agent
用户已批准实现。保留 REGULAR_SUBMISSION 原始证据,将其从 Alpha 失败统计及待定判定中分离;HTTP/MCP 提供分类摘要,页面单独展示缓存限制;迁移仅重算历史 check_type。未知检查保持保守,不推断实时额度、恢复时间或正式提交资格。
验证:分类边界、模拟平台检查到持久化和 MCP/HTTP 返回、研究筛选、历史迁移、后端检查及前端构建。
## 完成记录
已实现并完成本地验证:后端 471 项全量测试通过;最终调整后 23 项专项测试通过;Ruff 与 git diff --check 通过;前端构建通过;Alpha 管理和工作空间 3 项浏览器回归通过。迁移 0018 在隔离 SQLite 中验证 503 条记录的升级、降级、再升级和原始证据保留。尚未部署或对业务数据库执行迁移;Docker 后端部署启动时按既有流程执行 alembic upgrade head。
+15 -4
View File
@@ -7,6 +7,7 @@ from datetime import datetime
from sqlalchemy import or_, select, update
from .models import Alpha, Research, ResearchTag, SelfCorrelation, now
from .platform_checks import split_checks, submission_limits
from .research.provenance import source_alpha_ids
METRIC_FIELDS = (
@@ -16,23 +17,24 @@ METRIC_FIELDS = (
def failed_checks(checks):
"""Return failed platform check names; local correlation never changes this list."""
"""Return failed Alpha check names, excluding submission limits and local correlation."""
return [
check.get("name") if isinstance(check.get("name"), str) else "未命名检查"
for check in checks if isinstance(check, dict) and check.get("result") == "FAIL"
for check in split_checks(checks)[0] if isinstance(check, dict) and check.get("result") == "FAIL"
] if isinstance(checks, list) else []
def snapshot_columns(settings, metrics, checks):
"""Derive list fields from a platform snapshot, preserving missing metrics as null.
Only explicit FAIL results count. Empty, malformed and unfinished checks are
Submission limits are excluded. Only explicit Alpha FAIL results count.
Empty, malformed and unfinished Alpha checks are
pending; all known checks passing without PROD_CORRELATION is only a pre-check.
No submission eligibility or activity eligibility is inferred here.
"""
settings = settings if isinstance(settings, dict) else {}
metrics = metrics if isinstance(metrics, dict) else {}
checks = checks if isinstance(checks, list) else []
checks, _ = split_checks(checks)
valid = [check for check in checks if isinstance(check, dict)]
failures = len(failed_checks(checks))
by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)}
@@ -64,6 +66,15 @@ def snapshot_columns(settings, metrics, checks):
}
def check_summary(checks):
"""Separate cached Alpha findings from submission limits; infer no live eligibility."""
return {
"check_type": snapshot_columns({}, {}, checks)["check_type"],
"failed_checks": failed_checks(checks),
"submission_limits": submission_limits(checks),
}
def submission_condition(submission):
"""Match the platform list contract; a missing status is never assumed submitted."""
return Alpha.status == "UNSUBMITTED" if submission == "UNSUBMITTED" else Alpha.status != "UNSUBMITTED"
+2 -2
View File
@@ -24,7 +24,7 @@ TOOLS = {
"search_data_preparations": (c.PreparationSearch, "preparations", "research:read", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"),
"get_data_preparation": (c.PreparationRead, "preparation", "research:read", "按集合 ID 与版本分页预览字段、类型、描述和数据集归属。提交回测时携带 preparation_refs,由服务端核对版本并固定独立输入快照;空集合不能用于研究。"),
"create_research_template": (c.CreateTemplate, "create_template", "research:write", "将调用方大模型研究后自行总结的参数化模板保存到模板工坊,供用户后续批量回测。先用 get_backtest_results 阅读实际指标和检查,选择 1–20 个已完成采集的 source_item_ids,并说明 hypothesis;不要把 completed 当作检查通过。template 使用 {name} 占位符及逐一对应的 variables,字段变量须声明 MATRIX/VECTOR/GROUP,VECTOR 聚合须明确写入表达式。提供唯一名称和 idempotency_key,可附 reference。返回模板 ID、版本和理论组合数;仅核验结构及来源,不验证所有参数组合,不再次调用模型、不执行回测、不覆盖已有模板。"),
"get_submission_check": (c.SelfCorrelationReference, "submission_check_context", "research:read", "读取已导入 Alpha 的表达式、Description、snapshot 和缓存检查结果;不发起检查。先核对或生成三段 Description,再调用 check_submission。"),
"get_submission_check": (c.SelfCorrelationReference, "submission_check_context", "research:read", "读取已导入 Alpha 的表达式、Description、snapshot 和缓存检查结果;check_summary 分离 Alpha 检查和 REGULAR_SUBMISSION 提交限制,原始 checks 保留;限制不代表当前额度。不发起检查。先核对或生成三段 Description,再调用 check_submission。"),
"check_submission": (c.SubmissionCheck, "check_submission", "research:refresh", "对单个待提交 Alpha 写回已获用户授权的 Description 并调用平台 GET /check,返回 job_id。须先用 get_submission_check 获取 snapshot;保留本地自相关门槛和冲突保护。通过 get_refresh_job 查进度、get_submission_check 读结果。无论检查结果如何,都不会调用 /submit 或正式提交 Alpha。"),
"get_worldquant_connection": (c.ConnectionReference, "connection", "research:read", "读取 WorldQuant 连接状态及可选认证 job_id 的进度,不发起认证;人工验证在网页完成。"),
"authenticate_worldquant": (c.Authentication, "authenticate", "research:refresh", "使用服务端已保存凭据连接或重新认证 WorldQuant,返回 job_id;action=connect(默认)或人工验证后 verify。用 get_worldquant_connection 查询,不接收密码,不修改账户配置。"),
@@ -38,7 +38,7 @@ TOOLS = {
"search_backtests": (c.History, "history", "research:read", "分页查历史候选与固定设置;candidates 按完整输入精确匹配,不推断数学等价。"),
"submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的固定批次,自动留痕并立即返回运行 ID。可携带 preparation_refs 选择集合,版本变化须重新读取;每项必须完整设置;重复默认拒绝,rerun 明确重跑。不需要研究资产。"),
"get_backtest": (c.RunReference, "run", "research:read", "读取真实运行进度、提交数量和可选增量事件;受理不等于成功。"),
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、全部非通过检查及三层状态;缺失指标不补零。"),
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、Alpha 非通过检查及三层状态;REGULAR_SUBMISSION 单列 submission_limits,不计入 Alpha 失败统计。缺失指标不补零。"),
"get_backtest_artifact": (c.Artifact, "artifact", "research:read", "分页读取候选脱敏快照的顶层键值或独立采集的 PnL;缺缓存不自动刷新。"),
"control_backtest": (c.Control, "control", "backtests:control", "对已授权运行暂停、继续、停止或恢复采集;不远程取消、不重提未知模拟。需要版本和幂等键。"),
}
+23
View File
@@ -0,0 +1,23 @@
"""Classify platform evidence without discarding unknown checks or inferring eligibility."""
def is_submission_limit(check):
"""Recognize only the confirmed account-limit check; unknown names remain Alpha checks."""
return isinstance(check, dict) and check.get("name") == "REGULAR_SUBMISSION"
def split_checks(checks):
"""Return Alpha checks and submission limits, retaining malformed Alpha evidence."""
items = checks if isinstance(checks, list) else []
return ([c for c in items if not is_submission_limit(c)],
[c for c in items if is_submission_limit(c)])
def submission_limits(checks):
"""Summarize the observed limit, never the account's current allowance or reset time."""
_, limits = split_checks(checks)
status = "blocked" if any(c.get("result") == "FAIL" for c in limits) else (
"not_blocked" if limits and all(c.get("result") == "PASS" for c in limits) else "unknown"
)
return {"status": status, "checks": limits,
"meaning": "仅反映缓存观测时的提交限制,不代表当前额度或正式提交资格"}
+7 -4
View File
@@ -7,6 +7,7 @@ from sqlalchemy import select
from ..backtests.service import Backtests, uid
from ..models import Alpha, ResearchEvaluation, ResearchExperiment, SelfCorrelation
from ..platform_checks import split_checks, submission_limits
from .experiments import Experiments
from .serialization import encode_snapshot as jsonable_encoder
@@ -30,13 +31,14 @@ def assess(snapshot, rules):
evidence.append(
{"metric": key, "value": value, "bound": bound, "direction": direction, "status": status}
)
checks = metrics.get("checks") or []
raw_checks = metrics.get("checks") or []
checks, _ = split_checks(raw_checks)
if not checks:
missing.append("platform_checks")
for check in checks:
if check.get("result") == "FAIL":
if isinstance(check, dict) and check.get("result") == "FAIL":
failed.append(f"platform:{check.get('name', 'unknown')}")
unknown_checks = [check for check in checks if check.get("result") not in ("PASS", "FAIL")]
unknown_checks = [check for check in checks if not isinstance(check, dict) or check.get("result") not in ("PASS", "FAIL")]
if unknown_checks:
missing.append("unresolved_platform_checks")
return {
@@ -44,7 +46,8 @@ def assess(snapshot, rules):
"evidence": evidence,
"failed": failed,
"missing": missing,
"existing_platform_checks": checks,
"existing_platform_checks": raw_checks,
"submission_limits": submission_limits(raw_checks),
"meaning": "本地研究筛选结果,不是官方提交资格",
}
+6 -1
View File
@@ -7,6 +7,7 @@ from sqlalchemy import func, select
from ..alphas import number, sanitize
from ..backtests.contracts import fingerprint
from ..models import BacktestItem, BacktestResult, BacktestRun, Pnl
from ..platform_checks import is_submission_limit
from ..research.serialization import encode_snapshot
@@ -26,6 +27,8 @@ def checks_summary(snapshot):
if "checks" in snapshot:
raw = snapshot["checks"]
checks.extend({"section": "root", "raw": c} for c in (raw if isinstance(raw, list) else [raw]))
submission_checks = [c for c in checks if is_submission_limit(c["raw"])]
checks = [c for c in checks if not is_submission_limit(c["raw"])]
counts = Counter({key: 0 for key in ("PASS", "FAIL", "PENDING", "WARNING", "UNKNOWN")})
non_pass = []
for check in checks:
@@ -36,7 +39,9 @@ def checks_summary(snapshot):
if state != "PASS":
non_pass.append({**check, "status": state})
return {"status": "unknown" if not checks else "reported", "counts": dict(counts),
"total": len(checks), "non_pass": non_pass}
"total": len(checks), "non_pass": non_pass,
"submission_limits": submission_checks,
"meaning": "Alpha 检查统计不含提交限制;限制为快照观测,不代表实时提交资格"}
def item_summary(item, result):
+2 -1
View File
@@ -9,6 +9,7 @@ from uuid import uuid4
from sqlalchemy import func, select
from ..alphas import check_summary
from ..backtests.contracts import ControlInput, DraftInput, PreviewInput, Source, StartInput, fingerprint
from ..backtests.service import Backtests
from ..business import Business
@@ -221,7 +222,7 @@ class ResearchAccess:
return {"alpha_id": args.alpha_id, "snapshot": submission_fingerprint(context),
**context, "descriptions": {key: item["description"] for key, item in context["sections"].items()},
"can_check": alpha.status == "UNSUBMITTED" and await correlation_allows_check(self.db, args.alpha_id),
"checks": alpha.checks, "source": "local_cache", "production_submission": False,
"checks": alpha.checks, "check_summary": check_summary(alpha.checks), "source": "local_cache", "production_submission": False,
"job_id": job.id if job else None, "job_status": job.status if job else None,
"checked_at": job.checkpoint.get("checked_at") if job else None}
+3 -1
View File
@@ -15,7 +15,7 @@ from pydantic_ai.usage import UsageLimits
from sqlalchemy import select
from .ai.provider import public_error
from .alphas import code, sanitize, snapshot_columns, submission_condition
from .alphas import check_summary, code, sanitize, snapshot_columns, submission_condition
from .jobs import ACTIVE
from .models import Account, AISettings, Alpha, Job, JobItem, SelfCorrelation, now
from .schemas import Contract, JobOutput, valid_ids
@@ -206,6 +206,8 @@ def router(runner, ai):
)
return {
"snapshot": fingerprint(context),
"checks": alpha.checks,
"check_summary": check_summary(alpha.checks),
"sections": context["sections"],
"descriptions": {key: item["description"] for key, item in context["sections"].items()},
"model": config.description_model,
@@ -0,0 +1,55 @@
"""Reclassify cached Alpha checks without changing upstream evidence."""
import sqlalchemy as sa
from alembic import op
revision = "0018"
down_revision = "0017"
branch_labels = None
depends_on = None
def check_type(checks, separate_limits):
"""Frozen classification for reversible data migration; never import mutable app code."""
checks = checks if isinstance(checks, list) else []
if separate_limits:
checks = [c for c in checks if not (isinstance(c, dict) and c.get("name") == "REGULAR_SUBMISSION")]
valid = [c for c in checks if isinstance(c, dict)]
failures = sum(c.get("result") == "FAIL" for c in valid)
if failures:
return "FAIL_1" if failures == 1 else "FAIL_2"
if not checks or len(valid) != len(checks) or any(c.get("result") != "PASS" for c in valid):
return "PENDING"
return "PASS" if any(c.get("name") == "PROD_CORRELATION" for c in valid) else "PRE_CHECK"
def reclassify(separate_limits):
"""Update only affected derived columns, in bounded batches; raw snapshots stay intact."""
table = sa.table("alphas", sa.column("id", sa.String()), sa.column("checks", sa.JSON()),
sa.column("check_type", sa.String()))
connection = op.get_bind()
last_id = None
while True:
query = sa.select(table.c.id, table.c.checks).order_by(table.c.id).limit(500)
if last_id is not None:
query = query.where(table.c.id > last_id)
rows = connection.execute(query).mappings().all()
if not rows:
break
updates = [
{"snapshot_id": row["id"], "classification": check_type(row["checks"], separate_limits)}
for row in rows if isinstance(row["checks"], list) and any(
isinstance(c, dict) and c.get("name") == "REGULAR_SUBMISSION" for c in row["checks"])
]
if updates:
connection.execute(table.update().where(table.c.id == sa.bindparam("snapshot_id"))
.values(check_type=sa.bindparam("classification")), updates)
last_id = rows[-1]["id"]
def upgrade():
reclassify(True)
def downgrade():
reclassify(False)
+6 -1
View File
@@ -12,10 +12,13 @@ from tests.test_submission import FIELDS, Description, setup
@pytest.mark.parametrize("kind", ["REGULAR", "SUPER"])
@pytest.mark.parametrize("result", ["PASS", "FAIL"])
async def test_mcp_check_never_submits(mcp_app, kind, result, monkeypatch):
@pytest.mark.parametrize("limited", [False, True])
async def test_mcp_check_never_submits(mcp_app, kind, result, limited, monkeypatch):
from tests import test_submission
checks = [{"name": "PROD_CORRELATION", "result": result}]
if limited:
checks.append({"name": "REGULAR_SUBMISSION", "result": "FAIL"})
monkeypatch.setattr(test_submission, "CHECKS", checks)
sections = ["regular"] if kind == "REGULAR" else ["selection", "combo"]
platform = await setup(mcp_app, alpha(type=kind, **{s: {"code": "rank(close)"} for s in sections}))
@@ -35,6 +38,8 @@ async def test_mcp_check_never_submits(mcp_app, kind, result, monkeypatch):
assert job["status"] == "completed", job
data = await invoke(mcp_app, principal, "get_submission_check", {"alpha_id": "alpha1"})
assert data["checks"] == checks and data["checked_at"]
assert data["check_summary"]["check_type"] == ("PASS" if result == "PASS" else "FAIL_1")
assert data["check_summary"]["submission_limits"]["status"] == ("blocked" if limited else "unknown")
assert data["production_submission"] is False
assert platform.patches == [{s: {"description": args["descriptions"][s]} for s in sections}]
assert platform.calls.count(("GET", "/alphas/alpha1/check")) == 2
@@ -0,0 +1,51 @@
"""Upgrade cached classifications across batches while preserving platform evidence."""
from pathlib import Path
import sqlalchemy as sa
from alembic import command
from alembic.config import Config
from cryptography.fernet import Fernet
from app.models import now
def test_limit_migration_is_reversible_and_preserves_snapshots(tmp_path, monkeypatch):
path = tmp_path / "limits.db"
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{path}")
monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only")
monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode())
monkeypatch.setenv("WQ_EMAIL", "")
monkeypatch.setenv("WQ_PASSWORD", "")
root = Path(__file__).resolve().parents[1]
config = Config(str(root / "alembic.ini"))
config.set_main_option("script_location", str(root / "migrations"))
command.upgrade(config, "0017")
engine = sa.create_engine(f"sqlite:///{path}")
table = sa.Table("alphas", sa.MetaData(), autoload_with=engine)
limit = {"name": "REGULAR_SUBMISSION", "result": "FAIL"}
patterns = [
([{"name": "PROD_CORRELATION", "result": "PASS"}, limit], "FAIL_1", "PASS"),
([{"name": "LOW_SHARPE", "result": "FAIL"}, limit], "FAIL_2", "FAIL_1"),
([limit], "FAIL_1", "PENDING"),
([{"name": "LOW_SHARPE", "result": "PASS"}, limit], "FAIL_1", "PRE_CHECK"),
([{"name": "UNKNOWN", "result": "FAIL"}], "FAIL_1", "FAIL_1"),
]
with engine.begin() as db:
db.execute(table.insert(), [
{"id": f"old{i:04}", "hidden": False, "settings": {}, "os_metrics": {},
"is_metrics": {"checks": patterns[i % 5][0]}, "checks": patterns[i % 5][0],
"check_type": patterns[i % 5][1], "synced_at": now(),
"raw": {"is": {"checks": patterns[i % 5][0]}}}
for i in range(503)
])
for version, position in [("0018", 2), ("0017", 1), ("0018", 2)]:
(command.upgrade if version == "0018" else command.downgrade)(config, version)
with engine.connect() as db:
rows = db.execute(sa.select(table).order_by(table.c.id)).mappings().all()
assert len(rows) == 503
for i, row in enumerate(rows):
assert row["check_type"] == patterns[i % 5][position]
assert row["checks"] == row["is_metrics"]["checks"] == row["raw"]["is"]["checks"] == patterns[i % 5][0]
command.check(config)
engine.dispose()
+76
View File
@@ -0,0 +1,76 @@
"""Submission limits must not disqualify an otherwise passing Alpha."""
import pytest
from app.alphas import failed_checks, snapshot_columns
LIMIT = {"name": "REGULAR_SUBMISSION", "result": "FAIL", "value": 4, "limit": 4}
@pytest.mark.parametrize("checks,expected", [
([{"name": "PROD_CORRELATION", "result": "PASS"}], "PASS"),
([{"name": "LOW_SHARPE", "result": "PASS"}], "PRE_CHECK"),
([{"name": "LOW_SHARPE", "result": "FAIL"}], "FAIL_1"),
([], "PENDING"),
([None], "PENDING"),
([{"name": "UNKNOWN_CHECK", "result": "FAIL"}], "FAIL_1"),
([{"name": "PROD_CORRELATION", "result": "PENDING"}], "PENDING"),
])
def test_limit_does_not_change_alpha_verdict(checks, expected):
original = [*checks, LIMIT]
assert snapshot_columns({}, {}, original)["check_type"] == expected
assert "REGULAR_SUBMISSION" not in failed_checks(original)
assert original[-1] == LIMIT
@pytest.mark.parametrize("result", ["PASS", "PENDING", "WARNING", "UNRECOGNIZED"])
def test_all_limit_states_are_separate(result):
from app.alphas import check_summary
checks = [{"name": "PROD_CORRELATION", "result": "PASS"}, {**LIMIT, "result": result}]
summary = check_summary(checks)
assert summary["check_type"] == "PASS"
assert summary["submission_limits"]["status"] == ("not_blocked" if result == "PASS" else "unknown")
def test_research_and_mcp_evidence_keep_limits_separate():
from types import SimpleNamespace
from app.research.evaluations import assess
from app.research_access.queries import checks_summary
snapshot = {"is": {"sharpe": 2, "fitness": 2, "turnover": 0.1,
"checks": [{"name": "PROD_CORRELATION", "result": "PASS"}, LIMIT]}}
rules = SimpleNamespace(sharpe_min=1, fitness_min=1, turnover_max=0.5)
finding = assess(snapshot, rules)
assert finding["verdict"] == "pass" and finding["failed"] == []
assert finding["existing_platform_checks"] == snapshot["is"]["checks"]
assert finding["submission_limits"]["status"] == "blocked"
summary = checks_summary(snapshot)
assert summary["counts"]["FAIL"] == 0 and summary["total"] == 1
assert summary["non_pass"] == []
assert summary["submission_limits"] == [{"section": "is", "raw": LIMIT}]
snapshot["is"]["checks"] = [LIMIT]
assert assess(snapshot, rules)["verdict"] == "review"
assert checks_summary(snapshot)["status"] == "unknown"
async def test_http_check_persists_limit_without_failing_alpha(app, logged_in, monkeypatch):
from app.models import Alpha
from tests import test_submission
checks = [{"name": "PROD_CORRELATION", "result": "PASS"}, LIMIT]
monkeypatch.setattr(test_submission, "CHECKS", checks)
await test_submission.setup(app)
response = await test_submission.enqueue(logged_in)
assert response.status_code == 202
await app.state.runner.execute(response.json()["id"])
async with app.state.sessions() as db:
item = await db.get(Alpha, "alpha1")
assert item.check_type == "PASS"
assert item.checks == item.is_metrics["checks"] == item.raw["is"]["checks"] == checks
state = (await logged_in.get("/api/v1/alphas/alpha1/submission")).json()
assert state["job"]["status"] == "completed"
assert state["check_summary"]["submission_limits"]["status"] == "blocked"
rows = (await logged_in.get("/api/v1/alphas?check_type=PASS")).json()["items"]
assert rows[0]["id"] == "alpha1" and rows[0]["failed_checks"] == []
+39 -3
View File
@@ -474,16 +474,46 @@ function MetricTable({
}
/** IS and OS checks are separate observations; missing values never imply a pass. */
function PlatformChecks({ title, checks }: { title: string; checks: unknown }) {
function PlatformChecks({
title,
checks,
submissionLimit = false,
}: {
title: string;
checks: unknown;
submissionLimit?: boolean;
}) {
const rows = Array.isArray(checks)
? checks.filter(
(item): item is Record<string, unknown> =>
item !== null && typeof item === "object" && !Array.isArray(item),
)
: [];
const limits = rows.filter((check) => check.name === "REGULAR_SUBMISSION");
const alphaChecks = rows.filter(
(check) => check.name !== "REGULAR_SUBMISSION",
);
if (!submissionLimit && limits.length) {
return (
<>
<PlatformChecks title={title} checks={alphaChecks} />
<PlatformChecks
title={`${title} · 提交限制`}
checks={limits}
submissionLimit
/>
</>
);
}
return (
<section aria-label={title}>
<h4>{title}</h4>
{submissionLimit && (
<p className="muted">
以下为缓存观测时的提交限制,不计入 Alpha
失败项;当前额度需重新检查确认。
</p>
)}
{rows.length ? (
rows.map((check, i) => (
<div className="check-row" key={i}>
@@ -496,13 +526,19 @@ function PlatformChecks({ title, checks }: { title: string; checks: unknown }) {
check.result === "PASS"
? "green"
: check.result === "FAIL"
? "red"
? submissionLimit
? "orange"
: "red"
: check.result === "WARNING"
? "orange"
: "grey"
}
>
{check.result === "WARNING"
{submissionLimit && check.result === "FAIL"
? "提交受限(FAIL)"
: submissionLimit && check.result === "PASS"
? "当时未受限(PASS)"
: check.result === "WARNING"
? "警告(WARNING)"
: check.result === "PENDING"
? "待定(PENDING)"