diff --git a/README.md b/README.md
index 22ecc608..992af603 100644
--- a/README.md
+++ b/README.md
@@ -52,7 +52,7 @@
> ⚠️ **Security warning:** The X account `VibeTrading_HKU`, Virtuals project `101845`, and token contract `0x640BDBF77b6447E8b7DB7894cED84BD1c40571f4` are not official Vibe-Trading assets. We have never launched or endorsed any token or memecoin. Do not buy, connect a wallet, or sign anything. [Details](SECURITY.md#official-channels--impersonation).
-- **2026-09-28** 🛠️ **Source health and connector transparency**: scheduled public-source canaries report outages and data drift with bounded, credential-free probes ([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627)); BaoStock socket requests now have deadlines and serialize concurrent sessions ([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615)). Copilot credential lookups refresh after a short cache TTL ([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619)). A generated broker matrix preserves each profile’s paper/live permissions and labels declared capabilities separately from runtime verification ([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629)).
+- **2026-09-28** 🛠️ **Source health and connector transparency**: scheduled public-source canaries report outages and data drift with bounded, credential-free probes ([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627)); BaoStock socket requests now have deadlines and serialize concurrent sessions ([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615)). Copilot credential lookups refresh after a short cache TTL ([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619)). A generated broker matrix preserves each profile’s paper/live permissions and labels declared capabilities separately from runtime verification ([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629)). **Grounding and MT5 follow-up:** explicit evidence symbols and exact field references are preserved; localized grouped figures retain the existing numeric tolerance ([#1584](https://github.com/HKUDS/Vibe-Trading/pull/1584), [#1586](https://github.com/HKUDS/Vibe-Trading/pull/1586), [#1588](https://github.com/HKUDS/Vibe-Trading/pull/1588)). Text-only correction attempts are bounded ([#1600](https://github.com/HKUDS/Vibe-Trading/pull/1600)). MT5 search uses the selected terminal and refuses ambiguous broker aliases; backtest sandboxes receive only validated attachment settings ([#1597](https://github.com/HKUDS/Vibe-Trading/pull/1597), [#1598](https://github.com/HKUDS/Vibe-Trading/pull/1598)).
- **2026-09-27** 🛠️ **Verifiable run cards and safer research**: Run cards now show hashed backtest execution records and verified metric-to-CSV references in JSON, Markdown and Run Detail ([#1612](https://github.com/HKUDS/Vibe-Trading/pull/1612)). GTJA high/low recency factors use the correct day count and consistent tie handling ([#1604](https://github.com/HKUDS/Vibe-Trading/pull/1604)); India short covers check the buy-side circuit band ([#1608](https://github.com/HKUDS/Vibe-Trading/pull/1608)). Token usage remains readable without exempting arbitrary credential strings ([#1606](https://github.com/HKUDS/Vibe-Trading/pull/1606)), malformed MCP schemas retain their object properties ([#1607](https://github.com/HKUDS/Vibe-Trading/pull/1607)), and immediate-order goal checks preserve research questions ([#1605](https://github.com/HKUDS/Vibe-Trading/pull/1605)). Setup documentation now correctly identifies OpenRouter as the shipped default ([#1609](https://github.com/HKUDS/Vibe-Trading/issues/1609)).
@@ -1662,6 +1662,11 @@ and `symbols_get`, passes no login credentials, and does not read account or
position data or alter Market Watch. Without an explicit path it returns no
MT5 candidate.
+If several broker aliases match, specify the exact native symbol (for example,
+`EURUSDm`) or configure `symbol_suffix`; search never chooses an alias arbitrarily
+or substitutes a web-search result. Backtest sandboxes receive only a validated
+`terminal_path` and positive finite `timeout`, not account credentials.
+
Then:
```bash
diff --git a/README_ar.md b/README_ar.md
index b482fbfa..18a4121a 100644
--- a/README_ar.md
+++ b/README_ar.md
@@ -52,7 +52,7 @@
> ⚠️ **تحذير أمني:** حساب X باسم `VibeTrading_HKU`، ومشروع Virtuals رقم `101845`، وعقد التوكن `0x640BDBF77b6447E8b7DB7894cED84BD1c40571f4` كلّها غير رسمية ولا تتبع Vibe-Trading. لم نُطلق أو نؤيد مطلقًا أي توكن أو عملة ميم. لا تشترِ هذا التوكن، ولا تربط محفظتك، ولا توقّع أي شيء. [التفاصيل](SECURITY.md#official-channels--impersonation).
-- **2026-09-28** 🛠️ **سلامة مصادر البيانات ووضوح قدرات الوسطاء**: فحوص دورية للمصادر العامة دون بيانات اعتماد وبمهلة محددة ترصد أعطال الاتصال والبيانات ([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627)). أضيفت مهل لاتصالات BaoStock وتسلسل للجلسات المتزامنة ([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615))، وتجدد استعلامات بيانات اعتماد Copilot بعد انتهاء تخزين مؤقت قصير ([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619)). تفصل مصفوفة الوسطاء المولدة صلاحيات التداول التجريبي والفعلي لكل ملف، وتميز القدرات المعلنة عن التحقق التشغيلي ([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629)).
+- **2026-09-28** 🛠️ **سلامة مصادر البيانات ووضوح قدرات الوسطاء**: فحوص دورية للمصادر العامة دون بيانات اعتماد وبمهلة محددة ترصد أعطال الاتصال والبيانات ([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627)). أضيفت مهل لاتصالات BaoStock وتسلسل للجلسات المتزامنة ([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615))، وتجدد استعلامات بيانات اعتماد Copilot بعد انتهاء تخزين مؤقت قصير ([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619)). تفصل مصفوفة الوسطاء المولدة صلاحيات التداول التجريبي والفعلي لكل ملف، وتميز القدرات المعلنة عن التحقق التشغيلي ([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629)). **تحسينات التحقق وMT5**: تُحفظ رموز الأدوات الصريحة ومراجع الحقول الدقيقة، مع إبقاء حدود التسامح الرقمي عند قراءة الفواصل المحلية ([#1584](https://github.com/HKUDS/Vibe-Trading/pull/1584)، [#1586](https://github.com/HKUDS/Vibe-Trading/pull/1586)، [#1588](https://github.com/HKUDS/Vibe-Trading/pull/1588)). محاولات التصحيح النصي محدودة ([#1600](https://github.com/HKUDS/Vibe-Trading/pull/1600)). يبحث MT5 عبر المنصة المحددة ويرفض الأسماء البديلة الملتبسة؛ وتتلقى بيئة الاختبار المعزولة إعدادات اتصال متحققة فقط ([#1597](https://github.com/HKUDS/Vibe-Trading/pull/1597)، [#1598](https://github.com/HKUDS/Vibe-Trading/pull/1598)).
- **2026-09-27** 🛠️ **بطاقات تشغيل قابلة للتحقق وتحسينات للبحث**: تعرض JSON وMarkdown وصفحة تفاصيل التشغيل سجلات تنفيذ الاختبار التاريخي بصماتٍ مشفّرة ومراجع موثقة لمقاييس CSV ([#1612](https://github.com/HKUDS/Vibe-Trading/pull/1612)). صُحّح حساب الأيام وحالات التعادل في عوامل GTJA ([#1604](https://github.com/HKUDS/Vibe-Trading/pull/1604))، ويُفحص إغلاق المراكز القصيرة في الهند وفق حد السعر لجهة الشراء ([#1608](https://github.com/HKUDS/Vibe-Trading/pull/1608)). تبقى أعداد استخدام الرموز مقروءة دون السماح بسلاسل اعتماد عشوائية ([#1606](https://github.com/HKUDS/Vibe-Trading/pull/1606))، وتُستعاد خصائص مخططات MCP غير الصالحة ([#1607](https://github.com/HKUDS/Vibe-Trading/pull/1607)). يسمح فحص أهداف الأوامر الفورية بأسئلة البحث ([#1605](https://github.com/HKUDS/Vibe-Trading/pull/1605))، وتوضح الوثائق أن OpenRouter هو الإعداد الافتراضي ([#1609](https://github.com/HKUDS/Vibe-Trading/issues/1609)).
diff --git a/README_es.md b/README_es.md
index fda328cc..cb9ce235 100644
--- a/README_es.md
+++ b/README_es.md
@@ -52,7 +52,7 @@
> ⚠️ **Advertencia de seguridad:** la cuenta de X `VibeTrading_HKU`, el proyecto de Virtuals `101845` y el contrato de token `0x640BDBF77b6447E8b7DB7894cED84BD1c40571f4` no son activos oficiales de Vibe-Trading. Nunca hemos lanzado ni respaldado ningún token o memecoin. No compres, conectes una wallet ni firmes nada. [Detalles](SECURITY.md#official-channels--impersonation).
-- **2026-09-28** 🛠️ **Estado de fuentes y capacidades de brókeres**: las sondas periódicas de fuentes públicas, sin credenciales y con tiempo limitado, notifican fallos y anomalías de datos ([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627)). BaoStock incorpora plazos de comunicación y serializa sesiones concurrentes ([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615)); las consultas de credenciales de Copilot se renuevan tras una caché breve ([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619)). La matriz generada separa permisos de simulación y operaciones reales por perfil, sin confundir declaraciones con verificación práctica ([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629)).
+- **2026-09-28** 🛠️ **Estado de fuentes y capacidades de brókeres**: las sondas periódicas de fuentes públicas, sin credenciales y con tiempo limitado, notifican fallos y anomalías de datos ([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627)). BaoStock incorpora plazos de comunicación y serializa sesiones concurrentes ([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615)); las consultas de credenciales de Copilot se renuevan tras una caché breve ([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619)). La matriz generada separa permisos de simulación y operaciones reales por perfil, sin confundir declaraciones con verificación práctica ([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629)). **Mejoras de verificación y MT5**: se conservan los símbolos explícitos y las referencias exactas a campos; los números con separadores locales mantienen la tolerancia existente ([#1584](https://github.com/HKUDS/Vibe-Trading/pull/1584), [#1586](https://github.com/HKUDS/Vibe-Trading/pull/1586), [#1588](https://github.com/HKUDS/Vibe-Trading/pull/1588)). Las correcciones de solo texto tienen un límite de intentos ([#1600](https://github.com/HKUDS/Vibe-Trading/pull/1600)). MT5 busca en el terminal seleccionado y rechaza alias ambiguos; el entorno aislado del backtest solo recibe ajustes de conexión validados ([#1597](https://github.com/HKUDS/Vibe-Trading/pull/1597), [#1598](https://github.com/HKUDS/Vibe-Trading/pull/1598)).
- **2026-09-27** 🛠️ **Tarjetas verificables y mejoras en el análisis**: JSON, Markdown y Run Detail muestran registros hash de ejecución del backtest y referencias verificadas a métricas CSV ([#1612](https://github.com/HKUDS/Vibe-Trading/pull/1612)). Se corrigen el recuento de días y los empates en factores GTJA ([#1604](https://github.com/HKUDS/Vibe-Trading/pull/1604)), y el cierre de cortos en India comprueba el límite de precio del lado comprador ([#1608](https://github.com/HKUDS/Vibe-Trading/pull/1608)). El uso de tokens sigue visible sin permitir cadenas de credenciales arbitrarias ([#1606](https://github.com/HKUDS/Vibe-Trading/pull/1606)); se recuperan las propiedades de esquemas MCP malformados ([#1607](https://github.com/HKUDS/Vibe-Trading/pull/1607)). El filtro de órdenes inmediatas permite preguntas de análisis ([#1605](https://github.com/HKUDS/Vibe-Trading/pull/1605)), y la documentación identifica OpenRouter como configuración predeterminada ([#1609](https://github.com/HKUDS/Vibe-Trading/issues/1609)).
diff --git a/README_id.md b/README_id.md
index b7c1b7e6..7df8027e 100644
--- a/README_id.md
+++ b/README_id.md
@@ -53,7 +53,7 @@
> ⚠️ **Peringatan keamanan:** Akun X `VibeTrading_HKU`, proyek Virtuals `101845`, dan kontrak token `0x640BDBF77b6447E8b7DB7894cED84BD1c40571f4` bukan aset resmi Vibe-Trading. Kami tidak pernah meluncurkan atau mendukung token maupun memecoin apa pun. Jangan membeli, menghubungkan wallet, atau menandatangani apa pun. [Detail](SECURITY.md#official-channels--impersonation).
-- **2026-09-28** 🛠️ **Kesehatan sumber data dan transparansi broker**: pemeriksaan berkala sumber publik tanpa kredensial dan dengan batas waktu melaporkan gangguan koneksi serta anomali data ([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627)). BaoStock menambahkan tenggat komunikasi dan mengurutkan sesi bersamaan ([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615)); pencarian kredensial Copilot diperbarui setelah cache singkat berakhir ([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619)). Matriks broker yang dihasilkan otomatis memisahkan izin simulasi dan perdagangan riil per profil, serta membedakan kemampuan yang dideklarasikan dari verifikasi runtime ([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629)).
+- **2026-09-28** 🛠️ **Kesehatan sumber data dan transparansi broker**: pemeriksaan berkala sumber publik tanpa kredensial dan dengan batas waktu melaporkan gangguan koneksi serta anomali data ([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627)). BaoStock menambahkan tenggat komunikasi dan mengurutkan sesi bersamaan ([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615)); pencarian kredensial Copilot diperbarui setelah cache singkat berakhir ([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619)). Matriks broker yang dihasilkan otomatis memisahkan izin simulasi dan perdagangan riil per profil, serta membedakan kemampuan yang dideklarasikan dari verifikasi runtime ([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629)). **Perbaikan verifikasi dan MT5**: simbol eksplisit dan referensi bidang yang tepat dipertahankan; pemisah angka lokal tetap menggunakan toleransi numerik yang ada ([#1584](https://github.com/HKUDS/Vibe-Trading/pull/1584), [#1586](https://github.com/HKUDS/Vibe-Trading/pull/1586), [#1588](https://github.com/HKUDS/Vibe-Trading/pull/1588)). Percobaan koreksi khusus teks dibatasi ([#1600](https://github.com/HKUDS/Vibe-Trading/pull/1600)). MT5 mencari melalui terminal terpilih dan menolak alias broker yang ambigu; sandbox backtest hanya menerima pengaturan koneksi yang tervalidasi ([#1597](https://github.com/HKUDS/Vibe-Trading/pull/1597), [#1598](https://github.com/HKUDS/Vibe-Trading/pull/1598)).
- **2026-09-27** 🛠️ **Kartu eksekusi yang dapat diverifikasi dan perbaikan riset**: JSON, Markdown, dan Run Detail menampilkan catatan hash eksekusi backtest serta referensi metrik CSV yang terverifikasi ([#1612](https://github.com/HKUDS/Vibe-Trading/pull/1612)). Hitungan hari dan nilai ekstrem seri pada faktor GTJA diperbaiki ([#1604](https://github.com/HKUDS/Vibe-Trading/pull/1604)); penutupan posisi short India memeriksa batas harga sisi beli ([#1608](https://github.com/HKUDS/Vibe-Trading/pull/1608)). Penggunaan token tetap terbaca tanpa meloloskan string kredensial sembarang ([#1606](https://github.com/HKUDS/Vibe-Trading/pull/1606)), properti skema MCP yang rusak dipulihkan ([#1607](https://github.com/HKUDS/Vibe-Trading/pull/1607)), dan pemeriksaan tujuan pesanan langsung tetap mengizinkan pertanyaan riset ([#1605](https://github.com/HKUDS/Vibe-Trading/pull/1605)). Dokumentasi kini menyatakan OpenRouter sebagai pengaturan bawaan ([#1609](https://github.com/HKUDS/Vibe-Trading/issues/1609)).
diff --git a/README_ja.md b/README_ja.md
index 69c34f5b..74573ba5 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -52,7 +52,7 @@
> ⚠️ **セキュリティ警告:** Xアカウント `VibeTrading_HKU`、Virtualsプロジェクト `101845`、およびトークンコントラクト `0x640BDBF77b6447E8b7DB7894cED84BD1c40571f4` は、いずれもVibe-Trading公式のものではありません。Vibe-Tradingはこれまで、いかなるトークンやミームコインも発行・公認していません。購入、ウォレットの接続、署名は行わないでください。[詳細](SECURITY.md#official-channels--impersonation)
-- **2026-09-28** 🛠️ **データソースの健全性と接続機能の可視化**:認証情報を使わず時間制限付きで公開ソースを定期検査し、接続障害やデータ異常を報告します([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627))。BaoStock に通信期限と並行セッションの直列化を追加([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615))、Copilot の認証情報照会には短いキャッシュ期限を設定しました([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619))。自動生成のブローカー機能表はペーパー/実取引の権限をプロファイル別に示し、宣言と動作確認を区別します([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629))。
+- **2026-09-28** 🛠️ **データソースの健全性と接続機能の可視化**:認証情報を使わず時間制限付きで公開ソースを定期検査し、接続障害やデータ異常を報告します([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627))。BaoStock に通信期限と並行セッションの直列化を追加([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615))、Copilot の認証情報照会には短いキャッシュ期限を設定しました([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619))。自動生成のブローカー機能表はペーパー/実取引の権限をプロファイル別に示し、宣言と動作確認を区別します([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629))。 **数値検証と MT5 の追加修正**:明示された銘柄と正確なフィールド参照を保持し、地域別の桁区切りにも既存の数値許容幅を適用します([#1584](https://github.com/HKUDS/Vibe-Trading/pull/1584)、[#1586](https://github.com/HKUDS/Vibe-Trading/pull/1586)、[#1588](https://github.com/HKUDS/Vibe-Trading/pull/1588))。テキストのみの修正回数を制限します([#1600](https://github.com/HKUDS/Vibe-Trading/pull/1600))。MT5 は指定端末で検索し、曖昧なブローカー別名を拒否します。バックテストのサンドボックスには検証済みの接続設定だけを渡します([#1597](https://github.com/HKUDS/Vibe-Trading/pull/1597)、[#1598](https://github.com/HKUDS/Vibe-Trading/pull/1598))。
- **2026-09-27** 🛠️ **検証可能な実行カードと研究フローの改善**:JSON、Markdown、実行詳細に、ハッシュ化したバックテスト実行記録と検証済みの指標 CSV 参照を表示します([#1612](https://github.com/HKUDS/Vibe-Trading/pull/1612))。GTJA の高値・安値経過日数と同値時の処理を修正し([#1604](https://github.com/HKUDS/Vibe-Trading/pull/1604))、インド市場の空売り買い戻しは買い側の値幅制限を確認します([#1608](https://github.com/HKUDS/Vibe-Trading/pull/1608))。認証情報を露出せずトークン使用量を保持し([#1606](https://github.com/HKUDS/Vibe-Trading/pull/1606))、不正な MCP スキーマのオブジェクト属性を補完します([#1607](https://github.com/HKUDS/Vibe-Trading/pull/1607))。即時注文の目標チェックは研究目的の質問を許可し([#1605](https://github.com/HKUDS/Vibe-Trading/pull/1605))、設定文書の既定経路を OpenRouter と明記しました([#1609](https://github.com/HKUDS/Vibe-Trading/issues/1609))。
diff --git a/README_ko.md b/README_ko.md
index e32634f9..05d120c8 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -52,7 +52,7 @@
> ⚠️ **보안 경고:** X 계정 `VibeTrading_HKU`, Virtuals 프로젝트 `101845`, 토큰 컨트랙트 `0x640BDBF77b6447E8b7DB7894cED84BD1c40571f4`는 모두 Vibe-Trading 공식과 무관합니다. Vibe-Trading은 어떠한 토큰이나 밈코인도 발행하거나 공식적으로 지지한 적이 없습니다. 해당 토큰을 구매하거나 지갑을 연결하거나 어떠한 서명도 하지 마세요. [자세히 보기](SECURITY.md#official-channels--impersonation).
-- **2026-09-28** 🛠️ **데이터 소스 상태와 브로커 기능 공개**: 인증 정보 없이 시간 제한을 적용한 정기 검사로 공개 소스의 연결 장애와 데이터 이상을 보고합니다([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627)). BaoStock 통신 제한 시간과 동시 세션 직렬화를 추가하고([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615)), Copilot 자격 증명 조회 캐시에 짧은 만료 시간을 적용했습니다([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619)). 자동 생성 브로커 표는 프로필별 모의·실거래 권한을 구분하며, 선언된 기능과 실제 검증을 구별합니다([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629)).
+- **2026-09-28** 🛠️ **데이터 소스 상태와 브로커 기능 공개**: 인증 정보 없이 시간 제한을 적용한 정기 검사로 공개 소스의 연결 장애와 데이터 이상을 보고합니다([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627)). BaoStock 통신 제한 시간과 동시 세션 직렬화를 추가하고([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615)), Copilot 자격 증명 조회 캐시에 짧은 만료 시간을 적용했습니다([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619)). 자동 생성 브로커 표는 프로필별 모의·실거래 권한을 구분하며, 선언된 기능과 실제 검증을 구별합니다([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629)). **수치 검증 및 MT5 추가 수정**: 명시된 종목과 정확한 필드 참조를 보존하고 현지 숫자 구분에도 기존 허용 오차를 유지합니다([#1584](https://github.com/HKUDS/Vibe-Trading/pull/1584), [#1586](https://github.com/HKUDS/Vibe-Trading/pull/1586), [#1588](https://github.com/HKUDS/Vibe-Trading/pull/1588)). 텍스트 전용 수정 횟수를 제한합니다([#1600](https://github.com/HKUDS/Vibe-Trading/pull/1600)). MT5는 지정 터미널에서 검색하며 모호한 브로커 별칭을 거부합니다. 백테스트 샌드박스에는 검증된 연결 설정만 전달합니다([#1597](https://github.com/HKUDS/Vibe-Trading/pull/1597), [#1598](https://github.com/HKUDS/Vibe-Trading/pull/1598)).
- **2026-09-27** 🛠️ **검증 가능한 실행 카드와 연구 흐름 개선**: JSON, Markdown, 실행 상세 화면에 해시 기반 백테스트 실행 기록과 검증된 지표 CSV 참조를 표시합니다([#1612](https://github.com/HKUDS/Vibe-Trading/pull/1612)). GTJA 고점·저점 경과 일수와 동률 처리를 수정하고([#1604](https://github.com/HKUDS/Vibe-Trading/pull/1604)), 인도 시장 숏 청산은 매수 방향의 가격 제한을 확인합니다([#1608](https://github.com/HKUDS/Vibe-Trading/pull/1608)). 자격 증명 문자열을 노출하지 않고 토큰 사용량을 유지하며([#1606](https://github.com/HKUDS/Vibe-Trading/pull/1606)), 잘못된 MCP 스키마의 객체 속성을 복구합니다([#1607](https://github.com/HKUDS/Vibe-Trading/pull/1607)). 즉시 주문 목표 검사는 연구 질문을 허용하고([#1605](https://github.com/HKUDS/Vibe-Trading/pull/1605)), 설정 문서에 기본 경로가 OpenRouter임을 명시했습니다([#1609](https://github.com/HKUDS/Vibe-Trading/issues/1609)).
diff --git a/README_zh.md b/README_zh.md
index 49891c69..3c146fcc 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -52,7 +52,7 @@
> ⚠️ **安全警告:** X 账号 `VibeTrading_HKU`、Virtuals 项目 `101845` 及代币合约 `0x640BDBF77b6447E8b7DB7894cED84BD1c40571f4` 均非 Vibe-Trading 官方。我们从未发行或背书任何代币或 meme 币。请勿购买、连接钱包或签名。[详细说明](SECURITY.md#official-channels--impersonation)。
-- **2026-09-28** 🛠️ **数据源健康检查与券商能力透明化**:定时检查通过有时限、不携带凭据的公开数据源探测报告连接故障和数据异常([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627));BaoStock socket 请求增加超时控制,并串行隔离并发会话([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615))。Copilot 凭据查询在短期缓存到期后重新读取([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619))。自动生成的券商矩阵分别列出各 profile 的模拟盘/实盘权限,明确区分能力声明与运行验证([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629))。
+- **2026-09-28** 🛠️ **数据源健康检查与券商能力透明化**:定时检查通过有时限、不携带凭据的公开数据源探测报告连接故障和数据异常([#1627](https://github.com/HKUDS/Vibe-Trading/pull/1627));BaoStock socket 请求增加超时控制,并串行隔离并发会话([#1615](https://github.com/HKUDS/Vibe-Trading/pull/1615))。Copilot 凭据查询在短期缓存到期后重新读取([#1619](https://github.com/HKUDS/Vibe-Trading/pull/1619))。自动生成的券商矩阵分别列出各 profile 的模拟盘/实盘权限,明确区分能力声明与运行验证([#1629](https://github.com/HKUDS/Vibe-Trading/pull/1629))。 **数值核验与 MT5 补充修复**:保留证据中的显式标的及精确字段引用,本地化数字分组沿用既有数值容差([#1584](https://github.com/HKUDS/Vibe-Trading/pull/1584)、[#1586](https://github.com/HKUDS/Vibe-Trading/pull/1586)、[#1588](https://github.com/HKUDS/Vibe-Trading/pull/1588));纯文本纠错受次数限制([#1600](https://github.com/HKUDS/Vibe-Trading/pull/1600))。MT5 搜索使用指定终端,拒绝歧义券商后缀;回测沙箱只接收校验后的终端连接设置([#1597](https://github.com/HKUDS/Vibe-Trading/pull/1597)、[#1598](https://github.com/HKUDS/Vibe-Trading/pull/1598))。
- **2026-09-27** 🛠️ **可复核的运行卡片与更可靠的研究流程**:运行卡片在 JSON、Markdown 和运行详情页展示回测执行哈希记录及已核验的指标 CSV 引用([#1612](https://github.com/HKUDS/Vibe-Trading/pull/1612))。GTJA 高低点远近因子修正计日与并列极值处理([#1604](https://github.com/HKUDS/Vibe-Trading/pull/1604)),印度市场回测平空仓按买入方向检查涨跌停([#1608](https://github.com/HKUDS/Vibe-Trading/pull/1608))。token 用量可读,同时不放行任意凭据字符串([#1606](https://github.com/HKUDS/Vibe-Trading/pull/1606));异常 MCP schema 保留对象属性([#1607](https://github.com/HKUDS/Vibe-Trading/pull/1607));即时下单目标检查不再误拦研究问题([#1605](https://github.com/HKUDS/Vibe-Trading/pull/1605))。配置文档明确默认使用 OpenRouter([#1609](https://github.com/HKUDS/Vibe-Trading/issues/1609))。
diff --git a/agent/backtest/loaders/mt5_loader.py b/agent/backtest/loaders/mt5_loader.py
index b97eda0d..075158d0 100644
--- a/agent/backtest/loaders/mt5_loader.py
+++ b/agent/backtest/loaders/mt5_loader.py
@@ -14,7 +14,7 @@ logged-in terminal, which is the primary read-only path.
History depth is bounded by the terminal's "Max bars in chart" setting.
Broker account-type suffixes (Exness ``EURUSDm``) are discovered via
-``symbols_get`` and memoized; results are keyed by the caller's ORIGINAL
+``symbols_get`` without guessing between multiple matches; results are keyed by the caller's ORIGINAL
code so the runner's coverage check (``set(codes) - set(data_map)``) holds.
"""
@@ -22,6 +22,7 @@ from __future__ import annotations
import json
import logging
+import math
from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import ModuleType
@@ -54,10 +55,6 @@ _INTERVAL_MAP = {
#: launch the terminal), and chain resolution probes ``is_available`` often.
_init_state: bool | None = None
-#: base symbol → resolved broker symbol memo.
-_symbol_cache: dict[str, str] = {}
-
-
def _import_mt5() -> ModuleType | None:
"""Lazy-import the Windows-only SDK; ``None`` when absent."""
try:
@@ -87,13 +84,24 @@ def _ensure_initialized() -> bool:
return False
config = _read_mt5_config()
kwargs: dict[str, Any] = {}
- if config.get("login"):
- kwargs["login"] = int(config["login"])
- kwargs["password"] = str(config.get("password") or "")
- kwargs["server"] = str(config.get("server") or "")
- if config.get("timeout"):
- kwargs["timeout"] = int(float(config["timeout"]) * 1000)
- args = (str(config["terminal_path"]),) if config.get("terminal_path") else ()
+ try:
+ if config.get("login"):
+ kwargs["login"] = int(config["login"])
+ kwargs["password"] = str(config.get("password") or "")
+ kwargs["server"] = str(config.get("server") or "")
+ if "timeout" in config:
+ timeout = float(config["timeout"])
+ if not math.isfinite(timeout) or timeout <= 0:
+ raise ValueError("invalid timeout")
+ kwargs["timeout"] = int(timeout * 1000)
+ terminal_path = config.get("terminal_path", "")
+ if not isinstance(terminal_path, str) or "\x00" in terminal_path:
+ raise ValueError("invalid terminal path")
+ args = (terminal_path,) if terminal_path else ()
+ except (TypeError, ValueError, OverflowError):
+ logger.warning("mt5: invalid terminal attachment configuration")
+ _init_state = False
+ return False
if not args:
logger.warning(
"mt5: no terminal_path configured, so the SDK picks a terminal "
@@ -104,7 +112,7 @@ def _ensure_initialized() -> bool:
try:
ok = bool(mt5.initialize(*args, **kwargs))
except Exception as exc: # noqa: BLE001 - availability probe must not raise
- logger.warning("mt5: initialize raised: %s", exc)
+ logger.warning("mt5: initialize raised %s", type(exc).__name__)
ok = False
if not ok:
try:
@@ -126,31 +134,28 @@ def _to_query_base(code: str) -> str:
def _resolve_broker_symbol(mt5: ModuleType, code: str) -> str | None:
- """Resolve a project code to the broker's symbol name (memoized).
+ """Resolve a project code to one unambiguous broker symbol name.
Order: the raw token exactly (case-sensitive broker names pass through),
the normalized base, then suffix discovery via ``symbols_get(f"{base}*")``
- — shortest name first, then alphabetical, which deterministically picks
- ``EURUSDm`` over ``EURUSDz`` on Exness-style brokers.
+ — multiple matches are refused, requiring an exact native symbol.
"""
base = _to_query_base(code)
- cached = _symbol_cache.get(base)
- if cached is not None:
- return cached
raw = code.strip()
for candidate in dict.fromkeys((raw, base)): # ordered, deduped
if candidate and mt5.symbol_info(candidate) is not None:
- _symbol_cache[base] = candidate
return candidate
matches = sorted(
(getattr(info, "name", "") for info in (mt5.symbols_get(group=f"{base}*") or ())),
key=lambda name: (len(name), name),
)
- matches = [m for m in matches if m]
+ matches = sorted({m for m in matches if m and m.upper().startswith(base)})
+ if len(matches) > 1:
+ logger.warning("mt5: ambiguous broker symbol %r; specify the exact native symbol", code)
+ return None
if not matches:
logger.warning("mt5: symbol %r not offered by this broker (no match for %s*)", code, base)
return None
- _symbol_cache[base] = matches[0]
return matches[0]
@@ -219,7 +224,7 @@ class DataLoader:
try:
frame = cached_loader_fetch(
source=self.name,
- symbol=_to_query_base(clean),
+ symbol=clean,
timeframe=interval,
start_date=start_date,
end_date=end_date,
diff --git a/agent/src/agent/grounding/figures.py b/agent/src/agent/grounding/figures.py
index 1a3bc807..ece12273 100644
--- a/agent/src/agent/grounding/figures.py
+++ b/agent/src/agent/grounding/figures.py
@@ -23,12 +23,10 @@ from typing import Iterable, Sequence
from src.agent.grounding.identity import _CANONICAL_SYMBOL_RE
-#: Widest relative gap between a written figure and the precise value it rounds.
-#: This is a presentation-precision guard, not the raw evidence tolerance: two-decimal
-#: rendering of values below 1 can legitimately move by more than 0.5% while still
-#: rounding correctly. The written half-unit narrows this further, and materially coarse
-#: renderings such as 0.014 -> 0.01 remain outside this 1% band.
-ROUNDED_BAND = 0.01
+#: Maximum relative rounding gap; written precision can only narrow this.
+#: Coarse rounding such as 0.82467 -> 0.82 exceeds the existing 0.5% policy;
+#: the answer must retain more digits instead of widening its evidence band.
+ROUNDED_BAND = 0.005
#: The five roles a declaration may carry (spec §2).
ROLES = ("observed", "derived", "proposed", "cited", "count")
@@ -405,8 +403,9 @@ def _numbers(text: str, *, decimal_commas: bool | None = None) -> list[_Token]:
A comma is a decimal point when the integer part is exactly "0" ("0,666"),
or when a one- or two-digit fraction carries a percent, pp/bp or currency
- mark ("12,5 %", "3,95 EUR", "€3,95"). A document that writes an unambiguous
- decimal comma anywhere and no unambiguous grouping ("1,234,567",
+ mark ("12,5 %", "3,95 EUR", "€3,95"). Document-level notation needs
+ stronger evidence than a bare one-digit "0,1" pair; see
+ :func:`_writes_decimal_commas`. A document with that evidence and no grouping ("1,234,567",
"1,234.56") reads every single-comma number as a decimal, so "2,237" and
"−5,132%" beside "1,57%" are 2.237 and −5.132%, not 2237 and −5132%.
@@ -479,7 +478,8 @@ def _numbers(text: str, *, decimal_commas: bool | None = None) -> list[_Token]:
def _writes_decimal_commas(text: str) -> bool:
"""Whether a document writes decimal commas and never a thousands grouping.
- Evidence for a decimal comma is unambiguous on its own: a "0," integer part,
+ Evidence for a decimal comma is a zero integer part with at least two
+ fractional digits (a bare "0,1" could be a list and changes no other number),
dotted thousands with a decimal comma ("1.234,56"), a fraction of any length
carrying a percent/pp/bp mark ("17,9318145214327%"), or a one- or two-digit
fraction carrying a currency mark ("3,95 EUR"). An unmarked "1,50" or a lone
@@ -493,13 +493,13 @@ def _writes_decimal_commas(text: str) -> bool:
if body.count(",") >= 2 or ("," in body and "." in body):
grouped = True
elif body.startswith("0,"):
- decimal = True
+ decimal = len(body.split(",", 1)[1]) >= 2 or decimal
elif body.isdigit() and text[match.end() : match.end() + 1] == ",":
fraction = _digit_run(text, match.end() + 1)
stop = match.end() + 1 + len(fraction)
if (
- body == "0"
- or _percent_mark(text, stop)[0] > 0
+ (body == "0" and len(fraction) >= 2)
+ or (bool(fraction) and _percent_mark(text, stop)[0] > 0)
or (
1 <= len(fraction) <= 2
and (
@@ -677,8 +677,8 @@ def parse_figures_block(content: str) -> FiguresBlock:
# Four logical columns; a literal pipe may belong to the ref itself.
if stripped.startswith("|"):
stripped = stripped[1:].lstrip()
- if stripped.endswith("|"):
- stripped = stripped[:-1].rstrip()
+ if stripped.endswith("|"):
+ stripped = stripped[:-1].rstrip()
parts = [part.strip() for part in stripped.split("|", 3)]
if _is_header_or_rule(parts):
continue
diff --git a/agent/src/agent/grounding/identity.py b/agent/src/agent/grounding/identity.py
index e6b8535a..26857f90 100644
--- a/agent/src/agent/grounding/identity.py
+++ b/agent/src/agent/grounding/identity.py
@@ -345,6 +345,8 @@ def _infer_currency(symbol: str) -> str | None:
def _infer_instrument_type(symbol: str, candidate_type: Any = None) -> str:
"""Normalize provider types into the identity contract."""
raw = str(candidate_type or "").strip().casefold()
+ if raw == "cfd":
+ return "cfd"
if "fund" in raw or "etf" in raw or "trust" in raw:
return "fund"
if "crypto" in raw:
diff --git a/agent/src/agent/grounding/policies.py b/agent/src/agent/grounding/policies.py
index 82eaa1e9..2eae3130 100644
--- a/agent/src/agent/grounding/policies.py
+++ b/agent/src/agent/grounding/policies.py
@@ -734,7 +734,7 @@ class _PolicyMixin:
]
metrics = [
float(entry["value"])
- for entry in self._analysis_metrics
+ for entry in self._analysis_entries(symbol)
if key in (entry.get("call_id"), entry.get("tool"))
and entry.get("value") is not None
]
@@ -763,6 +763,25 @@ class _PolicyMixin:
metrics = []
return records, metrics
+ def _analysis_entries(self, symbol: str | None) -> list[dict[str, Any]]:
+ """Scope metrics by explicit observed symbols recorded on their call.
+
+ Symbol-less aggregate calls remain eligible. A multi-symbol call cannot
+ attribute its otherwise unlabelled metric to one particular instrument.
+ Symbol-labelled EvidenceRecords remain available through their own path.
+ """
+ if not symbol:
+ return list(self._analysis_metrics)
+ call_symbols: dict[str, set[str]] = {}
+ for record in self._evidence:
+ if record.symbol and record.status == "observed":
+ call_symbols.setdefault(record.call_id, set()).add(record.symbol)
+ return [
+ entry for entry in self._analysis_metrics
+ if not call_symbols.get(entry.get("call_id"))
+ or call_symbols[entry.get("call_id")] == {symbol}
+ ]
+
def _field_sources(
self, field: str, symbol: str | None
) -> tuple[list[EvidenceRecord], list[dict[str, Any]]]:
@@ -788,7 +807,7 @@ class _PolicyMixin:
]
entries = [
entry
- for entry in self._analysis_metrics
+ for entry in self._analysis_entries(symbol)
if named(entry.get("field")) and entry.get("value") is not None
]
return records, entries
@@ -1066,7 +1085,7 @@ class _PolicyMixin:
"""Metric values from completed analysis results and metric-named leaves."""
values = [
float(entry["value"])
- for entry in self._analysis_metrics
+ for entry in self._analysis_entries(symbol)
if entry.get("value") is not None
]
values.extend(
@@ -1129,10 +1148,10 @@ class _PolicyMixin:
"""Whether a figure is ``target`` correctly rounded to the digits it was written with.
Raw evidence uses the relative :data:`_TOLERANCE` band. A figure written
- with decimals uses the slightly wider presentation-rounding
- :data:`figures.ROUNDED_BAND`, then is narrowed to half a unit of its last
- written decimal. This lets a correct two-decimal rendering such as
- 0.82467 -> 0.82 survive without admitting materially coarse renderings.
+ with decimals uses :data:`figures.ROUNDED_BAND` (the same 0.5% cap),
+ narrowed to half a unit of its last written decimal. A sufficiently
+ precise rendering such as
+ 0.82467 -> 0.825 survive; 0.82 exceeds the 0.5% relative policy.
A figure written without decimals keeps the raw relative band alone: an
integer's precision is not known ("6,700" may be rounded to hundreds).
@@ -1193,7 +1212,7 @@ class _PolicyMixin:
# so it cannot choose between the tail-risk identities in it (#1425).
scoped_entries = [
entry
- for entry in self._analysis_metrics
+ for entry in self._analysis_entries(symbol)
if declaration.ref in (entry.get("call_id"), entry.get("tool"))
]
tail_risk = self._tail_risk_ref_required(
@@ -1281,12 +1300,12 @@ class _PolicyMixin:
if not symbol or not record.symbol or record.symbol == symbol
]
tail_risk = self._tail_risk_ref_required(
- figure, session_records, self._analysis_metrics
+ figure, session_records, self._analysis_entries(symbol)
)
session_identities: list[str] = []
if tail_risk:
session_sources = self._tail_risk_sources(
- session_records, self._analysis_metrics
+ session_records, self._analysis_entries(symbol)
)
session_identities = sorted({identity for identity, _ in session_sources})
blocked = {
@@ -1323,7 +1342,7 @@ class _PolicyMixin:
"the field it quotes",
ambiguous_sources=session_identities,
field_ref_candidates=self._tail_risk_field_refs(
- session_records, self._analysis_metrics
+ session_records, self._analysis_entries(symbol)
),
)
]
diff --git a/agent/src/agent/loop.py b/agent/src/agent/loop.py
index e04b9e8e..78fb3802 100644
--- a/agent/src/agent/loop.py
+++ b/agent/src/agent/loop.py
@@ -1357,6 +1357,7 @@ class AgentLoop:
# turn. Explicit grounding recovery (identity / missing price evidence)
# keeps tools available; ordinary correction turns do not.
grounding_correction_text_only = False
+ pending_grounding_draft = ""
llm_usage_summary = _new_llm_usage_summary(self.llm)
last_response_model: str | None = None
goal_continuations = 0
@@ -1732,31 +1733,46 @@ class AgentLoop:
# Not filtered — reset the consecutive-skip counter.
consecutive_content_filter_count = 0
+ forced_grounding_release = False
if correction_text_only and response.has_tool_calls:
- # Tools were deliberately not offered on this correction turn.
- # Defensive handling keeps a provider/model that nevertheless
- # emits a tool call from escaping the bounded revision path.
+ # An unoffered call consumes the same bounded correction
+ # budget as an invalid revised draft, but is never executed
+ # or inserted into the transcript as an unanswered tool call.
+ grounding_revisions += 1
trace.write(
{
"type": "grounding_correction_tool_call_blocked",
"iter": current_iter,
+ "round": grounding_revisions,
}
)
- messages.append(
- {
- "role": "system",
- "content": (
- "[SYSTEM] This is a grounding correction turn. "
- "Do not call tools. Revise the previous draft using "
- "the evidence already gathered, or remove claims "
- "that cannot be supported."
- ),
- }
+ if streamed_chars:
+ self._emit(
+ "stream_reset",
+ {"iter": current_iter, "reason": "grounding_tool_call_blocked"},
+ )
+ forced_grounding_release = (
+ grounding_revisions >= MAX_GROUNDING_REVISIONS
+ or iteration == self.max_iterations
)
- continue
+ if not forced_grounding_release:
+ messages.append(
+ {
+ "role": "user",
+ "content": (
+ "This is a grounding correction turn. "
+ "Do not call tools. Revise the previous draft using "
+ "the evidence already gathered, or remove claims "
+ "that cannot be supported."
+ ),
+ }
+ )
+ continue
- if not response.has_tool_calls:
- final_content = response.content or ""
+ if forced_grounding_release or not response.has_tool_calls:
+ # At the cap, release the last actual draft through the
+ # existing gate. The forbidden call's text is not an answer.
+ final_content = pending_grounding_draft if forced_grounding_release else response.content or ""
syntax_fallback_emitted = False
if not final_content:
empty_model_response_iter = iteration
@@ -1836,7 +1852,11 @@ class AgentLoop:
)
syntax_fallback_emitted = True
if self._grounding is not None:
- validation = self._grounding.validate_final_answer(final_content)
+ validation = (
+ self._grounding.revalidate(final_content)
+ if forced_grounding_release
+ else self._grounding.validate_final_answer(final_content)
+ )
if not validation.valid:
# A draft whose only defect is a missing provenance
# word (source / currency / symbol suffix) gets the
@@ -1867,34 +1887,35 @@ class AgentLoop:
# the gate, not answer text.
final_content = validation.released_text
if not validation.valid:
- trace.write_text_entry(
- {
- "type": "answer_rejected",
- "iter": current_iter,
- "issues": validation.issues,
- },
- field="content",
- value=final_content,
- offload_kind=f"answer-rejected-{current_iter}",
- )
- if not buffer_text_output and streamed_chars:
- # The stream showed this draft up to its first unchecked
- # number; the next draft or the released answer replaces it.
- self._emit(
- "stream_reset",
- {"iter": current_iter, "reason": "grounding_rejected"},
+ if not forced_grounding_release:
+ trace.write_text_entry(
+ {
+ "type": "answer_rejected",
+ "iter": current_iter,
+ "issues": validation.issues,
+ },
+ field="content",
+ value=final_content,
+ offload_kind=f"answer-rejected-{current_iter}",
+ )
+ if not buffer_text_output and streamed_chars:
+ # The stream showed this draft up to its first unchecked
+ # number; the next draft or the released answer replaces it.
+ self._emit(
+ "stream_reset",
+ {"iter": current_iter, "reason": "grounding_rejected"},
+ )
+ react_trace.append(
+ {
+ "type": "answer_rejected",
+ "issues": validation.issues,
+ }
+ )
+ messages.append(
+ {"role": "assistant", "content": final_content}
)
- react_trace.append(
- {
- "type": "answer_rejected",
- "issues": validation.issues,
- }
- )
- messages.append(
- {"role": "assistant", "content": final_content}
- )
recovery = self._grounding.recovery_action(validation)
- if recovery is not None and iteration < self.max_iterations:
+ if not forced_grounding_release and recovery is not None and iteration < self.max_iterations:
# Explicit bounded recovery is the one case where
# the next turn is allowed to research again.
grounding_correction_text_only = False
@@ -1925,13 +1946,15 @@ class AgentLoop:
)
final_content = ""
continue
- messages.append(
- {
- "role": "user",
- "content": f"{self._grounding.correction_prompt(validation)}",
- }
- )
+ if not forced_grounding_release:
+ messages.append(
+ {
+ "role": "user",
+ "content": f"{self._grounding.correction_prompt(validation)}",
+ }
+ )
rejected_draft = final_content
+ pending_grounding_draft = rejected_draft
final_content = ""
# The budget counts drafts rejected on this
# correction path; the last one is released with
@@ -1940,9 +1963,11 @@ class AgentLoop:
# rather than rewording), so a run that had to
# resolve its symbol first still gets a corrected
# draft.
- grounding_revisions += 1
+ if not forced_grounding_release:
+ grounding_revisions += 1
if (
- iteration < self.max_iterations
+ not forced_grounding_release
+ and iteration < self.max_iterations
and grounding_revisions < MAX_GROUNDING_REVISIONS
):
grounding_correction_text_only = True
@@ -2028,10 +2053,14 @@ class AgentLoop:
"text_delta",
{"delta": final_content[len(shown):], "iter": current_iter},
)
+ # The correction has ended. A goal continuation is a new
+ # research turn and must regain its normal tool access.
+ grounding_correction_text_only = False
+ pending_grounding_draft = ""
should_continue_goal = False
continuation_snapshot = None
_max_cont = _goal_max_continuations()
- if active_goal_id and session_id and _max_cont > 0:
+ if not forced_grounding_release and active_goal_id and session_id and _max_cont > 0:
try:
if goal_store is None:
from src.goal import GoalStore
diff --git a/agent/src/core/runner.py b/agent/src/core/runner.py
index 22a54157..8df5b3df 100644
--- a/agent/src/core/runner.py
+++ b/agent/src/core/runner.py
@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import logging
+import math
import os
import shutil
import subprocess
@@ -162,7 +163,13 @@ def _reexpose_mt5_config(src_root: Path, dst_root: Path) -> None:
return
if not isinstance(config, dict):
return
- subset = {key: config[key] for key in ("terminal_path", "timeout") if key in config}
+ subset: dict[str, Any] = {}
+ terminal_path = config.get("terminal_path")
+ if isinstance(terminal_path, str) and terminal_path.strip() and "\x00" not in terminal_path:
+ subset["terminal_path"] = terminal_path
+ timeout = config.get("timeout")
+ if type(timeout) in (int, float) and math.isfinite(timeout) and timeout > 0:
+ subset["timeout"] = timeout
if not subset:
return
try:
diff --git a/agent/src/tools/symbol_search_tool.py b/agent/src/tools/symbol_search_tool.py
index f3fdb92e..e3163153 100644
--- a/agent/src/tools/symbol_search_tool.py
+++ b/agent/src/tools/symbol_search_tool.py
@@ -254,6 +254,13 @@ class SymbolSearchTool(BaseTool):
if connector_source is not None and connector_status is not None:
sources[connector_source] = connector_status
candidates.extend(connector_hits)
+ if connector_source == "mt5":
+ # Broker-native identities must never be substituted by web search.
+ return json.dumps({
+ "ok": True, "market": "multi", "source": "symbol_search",
+ "data": {"query": query, "count": len(connector_hits[:limit]),
+ "candidates": connector_hits[:limit], "sources": sources},
+ }, ensure_ascii=False)
if crypto_pair is not None and not connector_hits:
# Resolving a pair must not require a broker account. The venue
# catalogs are public, unauthenticated REST — the same connectivity
@@ -292,10 +299,6 @@ class SymbolSearchTool(BaseTool):
)
sources["fx_normalizer"] = "ok"
- if connector_source == "mt5":
- # The selected terminal must confirm an MT5 identity.
- candidates = [c for c in candidates if c.get("source") == "mt5"]
-
if crypto_pair is not None:
# A pair query is an exact instrument assertion. Near-string Yahoo
# hits such as AETHUSDT-USD are different assets and must not enter
@@ -519,9 +522,6 @@ def _search_selected_connector(
"""Resolve an explicit pair against the active crypto connector, if supported."""
crypto_pair = _canonical_crypto_pair(query)
mt5_pair = _metal_or_fx_legs(query)
- if crypto_pair is None and mt5_pair is None:
- return [], None, None
-
# Lazy imports keep the generic symbol tool usable when optional connector
# dependencies are absent and avoid loading broker configuration at import.
from src.trading import profiles as trading_profiles
@@ -536,8 +536,6 @@ def _search_selected_connector(
if profile.connector == "binance" and crypto_pair is None:
return [], None, None
- if profile.connector == "mt5" and mt5_pair is None:
- return [], None, None
if profile.connector not in {"binance", "mt5"}:
return [], None, None
@@ -549,8 +547,9 @@ def _search_selected_connector(
limit=limit,
)
except Exception as exc: # noqa: BLE001 - one search source is non-fatal
- logger.debug("%s instrument search failed for %r: %s", source, query, exc)
- return [], source, f"connector search failed: {exc}"
+ logger.debug("%s instrument search failed (%s)", source, type(exc).__name__)
+ message = "connector search failed" if source == "mt5" else f"connector search failed: {exc}"
+ return [], source, message
if not isinstance(payload, dict) or str(payload.get("status")).casefold() != "ok":
message = (
@@ -563,8 +562,8 @@ def _search_selected_connector(
rows = payload.get("instruments")
rows = rows if isinstance(rows, list) else []
candidates: List[Dict[str, Any]] = []
- if profile.connector == "mt5" and mt5_pair is not None:
- requested_base = "".join(mt5_pair)
+ if profile.connector == "mt5":
+ requested_base = "".join(mt5_pair) if mt5_pair else query.strip().upper()
for row in rows:
if not isinstance(row, dict):
continue
@@ -574,13 +573,13 @@ def _search_selected_connector(
resolved_base = native_symbol.upper()
if resolved_base.endswith(".FX"):
resolved_base = resolved_base[:-3]
- if not resolved_base.startswith(requested_base):
+ if not (resolved_base.startswith(requested_base) if mt5_pair else resolved_base == requested_base):
continue
candidates.append(
{
- "symbol": query.strip().upper(),
+ "symbol": query.strip().upper() if mt5_pair else native_symbol,
"name": str(row.get("name") or native_symbol).strip() or None,
- "market": str(row.get("market") or "forex"),
+ "market": str(row.get("market") or "mt5"),
"type": str(row.get("type") or "cfd"),
"exchange": str(row.get("exchange") or row.get("venue") or "MT5"),
"venue": str(row.get("venue") or row.get("exchange") or "MT5"),
@@ -589,6 +588,8 @@ def _search_selected_connector(
"native_symbol": native_symbol,
}
)
+ if len({candidate["native_symbol"] for candidate in candidates}) > 1:
+ return [], source, "ambiguous broker symbol; specify an exact native symbol"
return candidates, source, "ok"
for row in rows:
diff --git a/agent/src/trading/connectors/mt5/_client.py b/agent/src/trading/connectors/mt5/_client.py
index f9fd9cc0..8f3d998c 100644
--- a/agent/src/trading/connectors/mt5/_client.py
+++ b/agent/src/trading/connectors/mt5/_client.py
@@ -10,6 +10,7 @@ session and reads symbol metadata without account or trading operations.
from __future__ import annotations
import json
+import math
import threading
from contextlib import contextmanager
from dataclasses import asdict, dataclass
@@ -277,16 +278,27 @@ def _catalog_session(cfg: MT5Config) -> Iterator[Any]:
"""Attach by terminal path for symbol metadata without account access."""
if not cfg.terminal_path:
raise MT5ConfigError("terminal_path is required for MT5 symbol search")
+ if not math.isfinite(cfg.timeout) or cfg.timeout <= 0:
+ raise MT5ConfigError("MT5 timeout must be finite and positive")
mt5 = _require_mt5()
with _MT5_LOCK:
- if not mt5.initialize(cfg.terminal_path, timeout=int(cfg.timeout * 1000)):
- raise MT5ConnectionError(
- f"MT5 initialize failed ({_last_error(mt5)})"
- )
try:
+ if not mt5.initialize(cfg.terminal_path, timeout=int(cfg.timeout * 1000)):
+ raise MT5ConnectionError("MT5 terminal catalog attach failed")
yield mt5
+ except _CATALOG_ERRORS:
+ raise
+ except Exception as exc:
+ # Native SDK diagnostics may contain account or local path details.
+ raise MT5ConnectionError("MT5 terminal catalog operation failed") from exc
finally:
- mt5.shutdown()
+ try:
+ mt5.shutdown()
+ except Exception:
+ pass
+
+
+_CATALOG_ERRORS = (MT5ConfigError, MT5ConnectionError, MT5DependencyError)
def _assert_profile(cfg: MT5Config, account: Any, mt5: Any) -> None:
@@ -317,32 +329,40 @@ def _assert_profile(cfg: MT5Config, account: Any, mt5: Any) -> None:
)
-def _resolve_symbol(mt5: Any, cfg: MT5Config, symbol: str) -> str:
- """Resolve a project symbol to the broker's Market Watch name.
-
- Tries the configured suffix first, then the bare base, then discovers
- suffixed variants via ``symbols_get`` (shortest match wins, which picks
- ``EURUSDm`` over ``EURUSDz`` deterministically on Exness).
- """
- base = normalize_base(symbol)
- if not base:
- raise MT5ConfigError("symbol is required")
- candidates = []
+def _catalog_symbol(mt5: Any, cfg: MT5Config, symbol: str) -> str:
+ """Resolve metadata without selecting a symbol or guessing between aliases."""
+ raw = symbol.strip()
+ if raw.upper().endswith("=X"):
+ raw = raw[:-2]
+ base = normalize_base(raw)
+ if not base or not all(char.isalnum() or char == "." for char in base):
+ raise MT5ConfigError("symbol search requires an explicit symbol")
+ # A caller naming a native case-sensitive symbol keeps that exact identity.
+ # A configured suffix is an explicit preference for canonical pair queries.
+ candidates = [raw] if raw != base else []
if cfg.symbol_suffix:
candidates.append(base + cfg.symbol_suffix)
- candidates.append(base)
- name = next((c for c in candidates if mt5.symbol_info(c) is not None), None)
- if name is None:
- matches = sorted(
- (getattr(info, "name", "") for info in (mt5.symbols_get(group=f"{base}*") or ())),
- key=len,
- )
- matches = [m for m in matches if m]
- if not matches:
- raise MT5ConfigError(
- f"symbol {symbol!r} not offered by this broker (no match for {base}*)"
- )
- name = matches[0]
+ candidates.extend((raw, base))
+ for name in dict.fromkeys(candidates):
+ if mt5.symbol_info(name) is not None:
+ return name
+ rows = mt5.symbols_get(group=f"{base}*")
+ if rows is None:
+ raise MT5ConnectionError("MT5 symbol catalog unavailable")
+ matches = sorted({
+ str(getattr(info, "name", "")) for info in rows
+ if str(getattr(info, "name", "")).upper().startswith(base)
+ })
+ if len(matches) > 1:
+ raise MT5ConfigError("ambiguous broker symbol; specify an exact native symbol or configured suffix")
+ if not matches:
+ raise MT5ConfigError("symbol not offered by the configured MT5 terminal")
+ return matches[0]
+
+
+def _resolve_symbol(mt5: Any, cfg: MT5Config, symbol: str) -> str:
+ """Resolve one broker identity, then select it in Market Watch."""
+ name = _catalog_symbol(mt5, cfg, symbol)
if not mt5.symbol_select(name, True):
raise MT5ConfigError(f"symbol {name!r} could not be selected in Market Watch")
return name
diff --git a/agent/src/trading/connectors/mt5/reads.py b/agent/src/trading/connectors/mt5/reads.py
index 2d1585e3..c315030a 100644
--- a/agent/src/trading/connectors/mt5/reads.py
+++ b/agent/src/trading/connectors/mt5/reads.py
@@ -248,12 +248,6 @@ def search_instruments(
) -> dict[str, Any]:
"""Resolve one explicit symbol against the configured terminal catalog."""
cfg = config or _client.load_config()
- raw = str(query or "").strip().upper()
- base = raw[:-2] if raw.endswith("=X") else raw
- base = _client.normalize_base(base)
- if not base or not base.isalnum():
- return _error(cfg, "symbol search requires an explicit symbol", instruments=[])
-
try:
bounded_limit = max(1, min(int(limit), 50))
except (TypeError, ValueError, OverflowError):
@@ -261,30 +255,10 @@ def search_instruments(
try:
with _client._catalog_session(cfg) as mt5:
- candidates = []
- if cfg.symbol_suffix:
- candidates.append(base + cfg.symbol_suffix)
- candidates.append(base)
- info = None
- for name in candidates:
- info = mt5.symbol_info(name)
- if info is not None:
- break
- if info is None:
- matches = sorted(
- (
- item
- for item in (mt5.symbols_get(group=f"{base}*") or ())
- if str(getattr(item, "name", "")).upper().startswith(base)
- ),
- key=lambda item: (
- len(str(getattr(item, "name", ""))),
- str(getattr(item, "name", "")),
- ),
- )
- info = matches[0] if matches else None
+ name = _client._catalog_symbol(mt5, cfg, str(query or ""))
+ info = mt5.symbol_info(name)
except _MT5_ERRORS as exc:
- return _error(cfg, str(exc), instruments=[])
+ return {"status": "error", "error": str(exc), "instruments": [], "catalog_only": True}
instruments = []
if info is not None:
@@ -301,7 +275,7 @@ def search_instruments(
"venue": cfg.server or "MT5",
}
)
- return _envelope(cfg, query=query, instruments=instruments[:bounded_limit])
+ return {"status": "ok", "query": query, "instruments": instruments[:bounded_limit], "catalog_only": True}
def get_historical_bars(
diff --git a/agent/src/trading/service.py b/agent/src/trading/service.py
index b60cd12d..f1f688c9 100644
--- a/agent/src/trading/service.py
+++ b/agent/src/trading/service.py
@@ -378,6 +378,8 @@ def search_instruments(
),
)
if profile.connector == "mt5" and profile.transport == "broker_sdk":
+ if "terminal_path" in overrides:
+ return {"status": "error", "error": "MT5 search uses the configured terminal path", "instruments": []}
module = _sdk_module(profile.connector)
return _with_profile(
profile,
diff --git a/agent/tests/test_grounding_numeric_locale_rounding.py b/agent/tests/test_grounding_numeric_locale_rounding.py
index 32fe1860..a3999ea9 100644
--- a/agent/tests/test_grounding_numeric_locale_rounding.py
+++ b/agent/tests/test_grounding_numeric_locale_rounding.py
@@ -138,7 +138,7 @@ def test_zero_decimal_comma_sets_document_locale() -> None:
def test_live_shape_links_localized_prose_to_precise_declarations() -> None:
content = (
"PAMP.BA (Yahoo, ARS): cierre AR$5.165; volumen 502.408; "
- "ratio de volumen 0,82."
+ "ratio de volumen 0,825."
+ _figures(
"5165.0 | observed | latest_close | technical_indicators",
"502408.0 | observed | indicators.volume.latest | technical_indicators",
@@ -149,16 +149,16 @@ def test_live_shape_links_localized_prose_to_precise_declarations() -> None:
claims = {
figure.text: figure
for figure in scan_figures(content, block)
- if figure.text in {"5.165", "502.408", "0,82"}
+ if figure.text in {"5.165", "502.408", "0,825"}
}
- assert set(claims) == {"5.165", "502.408", "0,82"}
+ assert set(claims) == {"5.165", "502.408", "0,825"}
assert claims["5.165"].value == 5165.0
assert claims["502.408"].value == 502408.0
- assert claims["0,82"].value == 0.82
+ assert claims["0,825"].value == 0.825
assert block.match(5165.0, False, claims["5.165"].digits) is not None
assert block.match(502408.0, False, claims["502.408"].digits) is not None
- assert block.match(0.82, False, claims["0,82"].digits) is not None
+ assert block.match(0.825, False, claims["0,825"].digits) is not None
def test_spanish_grouped_tool_values_survive_the_grounding_gate(tmp_path: Path) -> None:
@@ -187,7 +187,7 @@ def test_spanish_grouped_tool_values_survive_the_grounding_gate(tmp_path: Path)
)
result = ledger.validate_final_answer(
"PAMP.BA (Yahoo, ARS): cierre AR$5.165; volumen 502.408; "
- "ratio de volumen 0,82."
+ "ratio de volumen 0,825."
+ _figures(
"5165.0 | observed | latest_close | technical_indicators",
"502408.0 | observed | indicators.volume.latest | technical_indicators",
@@ -332,10 +332,10 @@ def test_an_undeclared_figure_is_grounded_only_within_its_own_rounding(
(38.68005857871268, False, 38.50, "38.50", False, False),
(38.68005857871268, False, 39.0, "39", False, False),
(2.63949965, False, 2.64, "2.64", False, True),
- # A mathematically correct two-decimal rendering below 1 may move by
- # slightly more than the raw 0.5% evidence tolerance.
- (0.8246699017713774, False, 0.82, "0.82", False, True),
- # The widened presentation band remains bounded: this coarse rendering
+ # Keep the existing 0.5% safety band: write more digits when needed.
+ (0.8246699017713774, False, 0.82, "0.82", False, False),
+ (0.8246699017713774, False, 0.825, "0.825", False, True),
+ # The presentation band remains bounded: this coarse rendering
# loses 28% of the value and must not borrow the declaration.
(0.014, False, 0.01, "0.01", False, False),
(2.6395, False, 2.50, "2.50", False, False),
diff --git a/agent/tests/test_grounding_release.py b/agent/tests/test_grounding_release.py
index d3c472a8..1eae2a11 100644
--- a/agent/tests/test_grounding_release.py
+++ b/agent/tests/test_grounding_release.py
@@ -650,7 +650,7 @@ def _tool_call(call_id: str, tool_name: str, **arguments: Any) -> SimpleNamespac
def _run(
- tmp_path: Path, llm: _ScriptedLLM, *, max_iterations: int
+ tmp_path: Path, llm: _ScriptedLLM, *, max_iterations: int, session_id: str = ""
) -> tuple[dict[str, Any], list[tuple[str, dict[str, Any]]], AgentLoop]:
registry = ToolRegistry()
registry.register(_ResolverTool(_resolver_payload()))
@@ -665,7 +665,7 @@ def _run(
run_dir = tmp_path / "run"
run_dir.mkdir()
agent.memory.run_dir = str(run_dir)
- return agent.run("请分析机器人ETF并给出买入价"), events, agent
+ return agent.run("请分析机器人ETF并给出买入价", session_id=session_id), events, agent
_SCRIPT_HEAD = [
@@ -751,8 +751,9 @@ def test_grounding_revision_turn_is_text_only(tmp_path: Path) -> None:
]
-def test_grounding_revision_blocks_unoffered_tool_calls(tmp_path: Path) -> None:
+def test_grounding_revision_blocks_unoffered_tool_calls(tmp_path: Path, monkeypatch) -> None:
"""A provider cannot escape correction-only mode by emitting a tool call anyway."""
+ monkeypatch.setattr("src.agent.loop.MAX_GROUNDING_REVISIONS", 3)
rejected = (
"562500.SS(Yahoo,CNY)最新收盘价 1.171 元。"
"建议买入价为 0.881。"
@@ -796,6 +797,119 @@ def test_grounding_revision_blocks_unoffered_tool_calls(tmp_path: Path) -> None:
# Correction-only survives the blocked attempt and remains text-only.
assert llm.tools_history[len(_SCRIPT_HEAD) + 1] is None
assert llm.tools_history[len(_SCRIPT_HEAD) + 2] is None
+ assert_system_messages_only_lead(llm.messages_history)
+
+
+@pytest.mark.parametrize("cap,max_iterations,expected_calls", [(2, 20, 4), (4, 20, 6), (8, 4, 4)])
+def test_forbidden_correction_calls_release_at_budget_or_final_iteration(
+ tmp_path: Path, monkeypatch, cap, max_iterations, expected_calls
+) -> None:
+ """Repeated tool requests cannot exhaust the run or replace the held draft."""
+ monkeypatch.setattr("src.agent.loop.MAX_GROUNDING_REVISIONS", cap)
+ rejected = HDR + "建议买入价为 0.881。"
+ forbidden = _Response(
+ content="THIS IS NOT THE ANSWER. Invented price 999.99.",
+ tool_calls=[_tool_call("forbidden", "get_market_data", codes=[SYMBOL])],
+ )
+ llm = _ScriptedLLM(_SCRIPT_HEAD + [_Response(content=rejected), forbidden])
+ result, events, agent = _run(tmp_path, llm, max_iterations=max_iterations)
+
+ assert llm.calls == expected_calls
+ assert result["status"] == "success" and result["degraded"] is True
+ assert "1.171" in result["content"] and _REDACTION_MARKER_ZH in result["content"]
+ assert "0.881" not in result["content"] and "999.99" not in result["content"]
+ assert "THIS IS NOT THE ANSWER" not in result["content"]
+ assert agent._grounding.revalidate(result["content"]).valid
+ # Rechecking a held draft is not another model-authored numerical draft.
+ assert agent._grounding.validation_count == 1
+ assert all(tools is None for tools in llm.tools_history[3:])
+ assert_system_messages_only_lead(llm.messages_history)
+ assert all(
+ not message.get("tool_calls") or all(call.get("id") != "forbidden" for call in message["tool_calls"])
+ for messages in llm.messages_history for message in messages
+ )
+ trace = TraceWriter.read(tmp_path / "run")
+ assert not any(e.get("type") == "tool_result" and e.get("call_id") == "forbidden" for e in trace)
+ assert len([e for e in trace if e.get("type") == "answer_rejected"]) == 1
+ assert len([e for e in trace if e.get("type") == "answer"]) == 1
+ assert len([e for e in trace if e.get("type") == "grounding_correction_tool_call_blocked"]) == expected_calls - 3
+ visible = ""
+ for kind, data in events:
+ if kind == "stream_reset":
+ visible = ""
+ elif kind == "text_delta":
+ visible += data["delta"]
+ assert visible == result["content"]
+
+
+def test_forbidden_correction_call_uses_safe_fallback_without_price_evidence(tmp_path: Path) -> None:
+ """Exhausted identity recovery still ends safely when the correction calls a tool."""
+ rejected = "机器人ETF 现价 1.171,建议买入。"
+ forbidden = _Response(tool_calls=[_tool_call("forbidden", "get_market_data", codes=[SYMBOL])])
+ # Two unresolved-identity recovery rounds, then one ordinary rejected draft.
+ llm = _ScriptedLLM([_Response(content=rejected)] * 3 + [forbidden])
+ result, _events, agent = _run(tmp_path, llm, max_iterations=20)
+ assert llm.calls == 4
+ assert result["status"] == "success" and result["degraded"] is True
+ assert result["content"] == agent._grounding.safe_fallback()
+ assert "1.171" not in result["content"]
+ assert agent._grounding.validation_count == 3
+ assert llm.tools_history[1] is not None and llm.tools_history[2] is not None
+ assert llm.tools_history[3] is None
+ assert_system_messages_only_lead(llm.messages_history)
+
+
+def test_explicit_grounding_recovery_keeps_tools_then_correction_removes_them(tmp_path: Path) -> None:
+ """Missing identity/evidence may research; a later numeric rewrite may not."""
+ rejected = HDR + "建议买入价为 0.881。"
+ llm = _ScriptedLLM(
+ [_Response(content="机器人ETF 现价 1.171。")]
+ + _SCRIPT_HEAD
+ + [_Response(content=rejected), _Response(content=HDR)]
+ )
+ result, _events, _agent = _run(tmp_path, llm, max_iterations=10)
+ assert result["status"] == "success" and not result.get("degraded")
+ assert llm.calls == 5
+ assert all(tools is not None for tools in llm.tools_history[:4])
+ assert llm.tools_history[4] is None
+ trace = TraceWriter.read(tmp_path / "run")
+ assert [e["action"] for e in trace if e.get("type") == "grounding_recovery"] == ["search_symbol"]
+ assert_system_messages_only_lead(llm.messages_history)
+
+
+@pytest.mark.parametrize("forbidden", [False, True])
+def test_goal_continuation_resets_correction_mode_but_not_a_forced_release(
+ tmp_path: Path, monkeypatch, forbidden
+) -> None:
+ snapshot = {"goal": {"status": "active"}}
+
+ class GoalStore:
+ def account_usage(self, **_kwargs):
+ pass
+
+ def get_goal_snapshot(self, _goal_id):
+ return snapshot
+
+ monkeypatch.setattr("src.goal.GoalStore", GoalStore)
+ monkeypatch.setattr("src.agent.loop.SESSIONS_DIR", tmp_path / "sessions")
+ monkeypatch.setattr("src.agent.loop.get_current_goal_context", lambda _: ("", "goal-test"))
+ monkeypatch.setattr("src.agent.loop._goal_max_continuations", lambda: 1)
+ monkeypatch.setattr("src.agent.loop.format_goal_continuation_prompt", lambda *a, **kw: "Continue the goal.")
+ revision = (
+ _Response(tool_calls=[_tool_call("forbidden", "get_market_data", codes=[SYMBOL])])
+ if forbidden else _Response(content=HDR)
+ )
+ llm = _ScriptedLLM(_SCRIPT_HEAD + [_Response(content=HDR + "建议买入价为 0.881。"), revision, _Response(content=HDR)])
+ result, _events, _agent = _run(tmp_path, llm, max_iterations=10, session_id="goal-session")
+ assert result["status"] == "success"
+ assert llm.tools_history[3] is None
+ if forbidden:
+ assert result["degraded"] is True
+ assert llm.calls == 4
+ else:
+ assert llm.calls == 5
+ assert llm.tools_history[4] is not None
+ assert_system_messages_only_lead(llm.messages_history)
def test_loop_repairs_a_missing_source_word_without_another_model_round(tmp_path: Path) -> None:
diff --git a/agent/tests/test_grounding_september28_safety.py b/agent/tests/test_grounding_september28_safety.py
new file mode 100644
index 00000000..0d01030b
--- /dev/null
+++ b/agent/tests/test_grounding_september28_safety.py
@@ -0,0 +1,103 @@
+"""Safety boundaries while combining symbol, ref and locale fixes."""
+
+from pathlib import Path
+
+import pytest
+
+from src.agent.grounding import GroundingLedger
+from src.agent.grounding.figures import _numbers, _writes_decimal_commas, parse_figures_block
+
+
+@pytest.mark.parametrize("prefix", ["0,", "0,1", "Values 0, 1"])
+def test_ambiguous_zero_list_does_not_change_other_figures(prefix):
+ text = prefix + "; observed price 5.165."
+ assert not _writes_decimal_commas(text)
+ assert _numbers(text)[-1].digits == "5.165"
+
+
+@pytest.mark.parametrize(("written", "accepted"), [("0.82", False), ("0.825", True)])
+def test_rounding_does_not_widen_the_existing_evidence_policy(tmp_path: Path, written, accepted):
+ import json
+
+ ledger = GroundingLedger(run_dir=tmp_path, user_message="Report the ratio")
+ ledger.ingest_tool_result(
+ tool_name="technical_indicators",
+ arguments={},
+ result=json.dumps({"ratio": 0.8246699017713774}),
+ call_id="c1",
+ success=True,
+ )
+ content = f"Ratio {written}.\n```figures\n0.8246699017713774 | observed | ratio | c1\n```"
+ assert ledger.validate_final_answer(content).valid is accepted
+
+
+def test_internal_pipe_in_ref_stays_exact():
+ block = parse_figures_block("```figures\n| 1.57% | observed | risk | x1::data.var_95|scope |\n```")
+ assert block.declarations[0].ref == "x1::data.var_95|scope"
+
+
+def _two_symbol_metrics(tmp_path):
+ import json
+
+ ledger = GroundingLedger(run_dir=tmp_path, user_message="Report AAPL.US and MSFT.US metrics")
+ for symbol, call_id, value in [("AAPL.US", "a", 0.0157), ("MSFT.US", "b", 0.0399)]:
+ ledger.ingest_tool_result(
+ tool_name="factor_analysis",
+ arguments={"symbol": symbol},
+ result=json.dumps({"status": "ok", "symbol": symbol, "var_95": value, "es_95": value + 0.01}),
+ call_id=call_id,
+ success=True,
+ )
+ return ledger
+
+
+def test_field_ref_candidates_and_values_never_borrow_other_symbol(tmp_path):
+ ledger = _two_symbol_metrics(tmp_path)
+ assert ledger._tool_field_ref_candidates("factor_analysis::var_95", "AAPL.US") == ["a::var_95"]
+ assert ledger._referenced("b::var_95", "AAPL.US", None) == ([], [])
+ assert 0.0399 not in ledger._metric_pool("AAPL.US")
+
+
+def test_candidate_corrections_are_scoped_to_claim_symbol(tmp_path):
+ ledger = _two_symbol_metrics(tmp_path)
+ result = ledger.validate_final_answer("AAPL.US risk 1.57%.")
+ issue = next(issue for issue in result.issues if issue.get("reason") == "tail_risk_needs_field_ref")
+ assert issue["field_ref_candidates"] == ["a::es_95", "a::var_95"]
+
+
+def test_unframed_ref_ending_in_pipe_is_not_truncated():
+ block = parse_figures_block("```figures\n1.57% | observed | risk | x1::data.risk|\n```")
+ assert block.declarations[0].ref == "x1::data.risk|"
+
+
+def test_pipe_field_requires_exact_call_when_values_disagree(tmp_path):
+ import json
+
+ ledger = GroundingLedger(run_dir=tmp_path, user_message="Report metrics")
+ for call_id, value in [("a", 1.57), ("b", 3.99)]:
+ ledger.ingest_tool_result(
+ tool_name="technical_indicators",
+ arguments={},
+ result=json.dumps({"ratio|window": value}),
+ call_id=call_id,
+ success=True,
+ )
+
+ def check(ref):
+ return ledger.validate_final_answer(f"Ratio 1.57.\n```figures\n1.57 | observed | ratio | {ref}\n```")
+
+ assert check("a::ratio|window").valid
+ assert not check("b::ratio|window").valid
+ assert not check("ratio|window").valid
+
+
+def test_multisymbol_call_does_not_attribute_unlabelled_analysis_to_one_symbol(tmp_path):
+ from dataclasses import replace
+
+ ledger = _two_symbol_metrics(tmp_path)
+ # A mixed call has two explicit record identities but no per-metric identity.
+ ledger._evidence.append(replace(ledger._evidence[-1], call_id="a"))
+ assert not any(entry["call_id"] == "a" for entry in ledger._analysis_entries("AAPL.US"))
+ # A truly symbol-less aggregate remains available.
+ ledger._analysis_metrics.append({"call_id": "aggregate", "field": "var_95", "value": 0.055})
+ assert any(entry["call_id"] == "aggregate" for entry in ledger._analysis_entries("AAPL.US"))
diff --git a/agent/tests/test_mt5_connector.py b/agent/tests/test_mt5_connector.py
index 19cbc49a..7c455ea3 100644
--- a/agent/tests/test_mt5_connector.py
+++ b/agent/tests/test_mt5_connector.py
@@ -982,3 +982,52 @@ class TestProfilesAndRegistration:
result = service.place_order("EURUSD", "mt5-live-trade", side="buy", quantity=0.05)
assert result["status"] == "blocked"
assert fake_mt5.order_send_requests == [] # gate blocks before any SDK call
+
+
+def test_catalog_ambiguity_is_not_silently_selected(fake_mt5):
+ from src.trading.connectors.mt5 import sdk
+ fake_mt5.symbols["XAUUSDz"] = SimpleNamespace(name="XAUUSDz")
+ result = sdk.search_instruments("XAUUSD", config=_paper_config(terminal_path="C:/mt5/terminal64.exe", symbol_suffix=""))
+ assert result["status"] == "error"
+ assert "ambiguous" in result["error"]
+ assert result["instruments"] == []
+ assert fake_mt5.order_send_requests == []
+
+
+def test_catalog_exact_native_case_preserved(fake_mt5):
+ from src.trading.connectors.mt5 import sdk
+ fake_mt5.symbols["XAUUSDz"] = SimpleNamespace(name="XAUUSDz")
+ result = sdk.search_instruments("XAUUSDz", config=_paper_config(terminal_path="C:/mt5/terminal64.exe"))
+ assert result["instruments"][0]["native_symbol"] == "XAUUSDz"
+ assert "paper_guard" not in result
+ assert "is_demo" not in result
+
+
+def test_catalog_failure_is_redacted_and_detached(fake_mt5):
+ from src.trading.connectors.mt5 import sdk
+ def failing(*args, **kwargs):
+ raise RuntimeError("private broker credential")
+ fake_mt5.symbol_info = failing
+ result = sdk.search_instruments("XAUUSD", config=_paper_config(terminal_path="C:/mt5/terminal64.exe"))
+ assert result["status"] == "error"
+ assert "private broker credential" not in str(result)
+ assert fake_mt5.shutdown_calls == 1
+
+
+def test_catalog_shutdown_does_not_hide_success(fake_mt5):
+ from src.trading.connectors.mt5 import sdk
+ def failing():
+ raise RuntimeError("detach failed")
+ fake_mt5.shutdown = failing
+ result = sdk.search_instruments("XAUUSD", config=_paper_config(terminal_path="C:/mt5/terminal64.exe"))
+ assert result["status"] == "ok"
+
+
+@pytest.mark.parametrize("timeout", [float("nan"), float("inf"), -1.0, 0.0])
+def test_catalog_invalid_timeout_does_not_attach(fake_mt5, timeout):
+ from src.trading.connectors.mt5 import sdk
+ from dataclasses import replace
+ cfg = replace(_paper_config(terminal_path="C:/mt5/terminal64.exe"), timeout=timeout)
+ result = sdk.search_instruments("XAUUSD", config=cfg)
+ assert result["status"] == "error"
+ assert fake_mt5.initialize_calls == []
diff --git a/agent/tests/test_mt5_loader.py b/agent/tests/test_mt5_loader.py
index 91ec51df..caef2652 100644
--- a/agent/tests/test_mt5_loader.py
+++ b/agent/tests/test_mt5_loader.py
@@ -57,7 +57,7 @@ class _FakeMT5Module:
def __init__(self) -> None:
self.initialize_result = True
self.initialize_calls: list[dict[str, Any]] = []
- self.symbol_names = ["EURUSDm", "EURUSDz", "XAUUSDm", "USDJPYm"]
+ self.symbol_names = ["EURUSDm", "XAUUSDm", "USDJPYm"]
self.rates: dict[str, np.ndarray | None] = {}
self.rates_calls: list[tuple[str, int, Any, Any]] = []
@@ -95,7 +95,6 @@ def fake_mod(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> _FakeMT5Module:
monkeypatch.setitem(sys.modules, "MetaTrader5", fake)
monkeypatch.setattr(mt5_loader, "_MT5_CONFIG_PATH", tmp_path / "mt5.json")
monkeypatch.setattr(mt5_loader, "_init_state", None)
- monkeypatch.setattr(mt5_loader, "_symbol_cache", {})
return fake
@@ -120,12 +119,11 @@ class TestQueryBase:
class TestResolution:
- def test_suffix_discovery_prefers_shortest_then_alpha(self, fake_mod: _FakeMT5Module) -> None:
- loader = DataLoader()
- frames = loader.fetch(["EUR/USD"], "2026-06-01", "2026-06-10")
- # EURUSDm and EURUSDz both match EURUSD*; deterministic pick is EURUSDm.
- assert fake_mod.rates_calls[0][0] == "EURUSDm"
- assert "EUR/USD" in frames
+ def test_ambiguous_suffix_discovery_fails_closed(self, fake_mod: _FakeMT5Module) -> None:
+ fake_mod.symbol_names.append("EURUSDz")
+ frames = DataLoader().fetch(["EUR/USD"], "2026-06-01", "2026-06-10")
+ assert frames == {}
+ assert fake_mod.rates_calls == []
def test_exact_symbol_wins_over_suffix(self, fake_mod: _FakeMT5Module) -> None:
fake_mod.symbol_names.append("EURUSD")
@@ -300,3 +298,17 @@ class TestRegistryWiring:
from src.market_data import detect_source
assert detect_source(code) == expected
+
+
+def test_resolution_rechecks_catalog_ambiguity(fake_mod):
+ assert mt5_loader._resolve_broker_symbol(fake_mod, "EUR/USD") == "EURUSDm"
+ fake_mod.symbol_names.append("EURUSDz")
+ assert mt5_loader._resolve_broker_symbol(fake_mod, "EUR/USD") is None
+ assert mt5_loader._resolve_broker_symbol(fake_mod, "EURUSDz") == "EURUSDz"
+
+
+@pytest.mark.parametrize("config", [{"timeout": "nan"}, {"timeout": "bad"}, {"timeout": -1}, {"terminal_path": {"password": "private"}}, {"login": "bad"}])
+def test_loader_invalid_config_degrades_without_attach(fake_mod, monkeypatch, config):
+ monkeypatch.setattr(mt5_loader, "_read_mt5_config", lambda: config)
+ assert DataLoader().is_available() is False
+ assert fake_mod.initialize_calls == []
diff --git a/agent/tests/test_runner_env.py b/agent/tests/test_runner_env.py
index ab0a85db..3b07bf9c 100644
--- a/agent/tests/test_runner_env.py
+++ b/agent/tests/test_runner_env.py
@@ -488,3 +488,14 @@ def test_execute_applies_address_space_rlimit(monkeypatch, tmp_path: Path) -> No
assert result.success, result.stderr
assert result.stdout.strip().splitlines()[-1] == str(expected)
+
+
+@pytest.mark.parametrize("value", [{"password": "must-not-copy"}, ["must-not-copy"], True, float("inf")])
+def test_mt5_sandbox_allowlist_rejects_nonprimitive_settings(tmp_path, value):
+ from src.core.runner import _reexpose_mt5_config
+ source, target = tmp_path / "source", tmp_path / "target"
+ source.mkdir()
+ target.mkdir()
+ (source / "mt5.json").write_text(json.dumps({"terminal_path": value, "timeout": value, "login": "private"}))
+ _reexpose_mt5_config(source, target)
+ assert not (target / "mt5.json").exists()
diff --git a/agent/tests/test_sdk_connectors.py b/agent/tests/test_sdk_connectors.py
index 148befeb..f21062ac 100644
--- a/agent/tests/test_sdk_connectors.py
+++ b/agent/tests/test_sdk_connectors.py
@@ -1003,3 +1003,12 @@ def test_shoonya_service_unconfigured(monkeypatch, tmp_path) -> None:
assert result["status"] == "error"
assert result["connector"] == "shoonya"
assert result["transport"] == "broker_sdk"
+
+
+def test_mt5_instrument_search_refuses_executable_override(monkeypatch):
+ def forbidden(*args, **kwargs):
+ raise AssertionError("must not attach to an overridden executable")
+ monkeypatch.setattr(mt5, "search_instruments", forbidden)
+ result = service.search_instruments("XAUUSD", "mt5-paper-sdk", terminal_path="C:/arbitrary.exe")
+ assert result["status"] == "error"
+ assert result["instruments"] == []
diff --git a/agent/tests/test_symbol_search_tool.py b/agent/tests/test_symbol_search_tool.py
index 459fed23..4b4c4d01 100644
--- a/agent/tests/test_symbol_search_tool.py
+++ b/agent/tests/test_symbol_search_tool.py
@@ -1151,3 +1151,64 @@ class TestSpotGoldCandidateFilter:
symbols = [c["symbol"] for c in data["candidates"]]
assert "BTC-USD" in symbols
assert "AAPL.US" in symbols
+
+
+@pytest.mark.parametrize("query", ["XAUUSDm", "EURUSDm", "US500.cash"])
+def test_selected_mt5_native_symbol_uses_only_terminal(monkeypatch, query):
+ monkeypatch.setattr(trading_profiles, "load_selected_profile_id", lambda: "mt5-paper-sdk")
+ monkeypatch.setattr(trading_service, "search_instruments", lambda *args, **kwargs: {
+ "status": "ok", "instruments": [{"symbol": query, "native_symbol": query, "market": "mt5", "type": "cfd"}]
+ })
+ def forbidden(*args, **kwargs):
+ raise AssertionError("MT5 identity must not use web search")
+ monkeypatch.setattr(ss, "_search_eastmoney", forbidden)
+ monkeypatch.setattr(ss, "_search_yahoo", forbidden)
+ data = json.loads(ss.SymbolSearchTool().execute(query=query))["data"]
+ assert data["count"] == 1
+ assert data["candidates"][0]["symbol"] == query
+ assert data["candidates"][0]["market"] == "mt5"
+
+
+def test_mt5_ambiguous_native_rows_are_not_deduplicated_into_identity(monkeypatch):
+ monkeypatch.setattr(trading_profiles, "load_selected_profile_id", lambda: "mt5-paper-sdk")
+ monkeypatch.setattr(trading_service, "search_instruments", lambda *args, **kwargs: {
+ "status": "ok", "instruments": [{"native_symbol": name} for name in ("XAUUSDm", "XAUUSDz")]
+ })
+ data = json.loads(ss.SymbolSearchTool().execute(query="XAUUSD"))["data"]
+ assert data["candidates"] == []
+ assert "ambiguous" in data["sources"]["mt5"]
+
+
+@pytest.mark.parametrize("query,native,market,kind", [
+ ("XAUUSDm", "XAUUSDm", "mt5", "cfd"),
+ ("EURUSDm", "EURUSDm", "fx", "forex"),
+ ("XAUUSD.FX", "XAUUSDm", "mt5", "cfd"),
+ ("EURUSD.FX", "EURUSDm", "fx", "forex"),
+])
+def test_mt5_selected_identity_reaches_grounding_without_substitution(monkeypatch, tmp_path, query, native, market, kind):
+ monkeypatch.setattr(trading_profiles, "load_selected_profile_id", lambda: "mt5-paper-sdk")
+ monkeypatch.setattr(trading_service, "search_instruments", lambda *args, **kwargs: {
+ "status": "ok", "instruments": [{"symbol": native, "native_symbol": native, "market": market, "type": kind, "venue": "MT5"}]
+ })
+ result = ss.SymbolSearchTool().execute(query=query)
+ ledger = GroundingLedger(run_dir=tmp_path, user_message=f"Quote {query}")
+ ledger.ingest_tool_result(tool_name="search_symbol", arguments={"query": query}, result=result, call_id="mt5-identity", success=True)
+ assert json.loads(result)["data"]["candidates"][0]["native_symbol"] == native
+ assert ledger.identity_status == "locked"
+ records = [record for record in ledger.identity_summary()["records"]
+ if record["source_tool_call_id"] == "mt5-identity"]
+ assert len(records) == 1
+ assert records[0]["instrument_type"] == kind
+ assert records[0]["symbol"] == query.upper()
+
+
+def test_mt5_ambiguous_catalog_leaves_grounding_unresolved(monkeypatch, tmp_path):
+ monkeypatch.setattr(trading_profiles, "load_selected_profile_id", lambda: "mt5-paper-sdk")
+ monkeypatch.setattr(trading_service, "search_instruments", lambda *args, **kwargs: {
+ "status": "ok", "instruments": [{"native_symbol": name} for name in ("XAUUSDm", "XAUUSDz")]
+ })
+ result = ss.SymbolSearchTool().execute(query="XAUUSD")
+ ledger = GroundingLedger(run_dir=tmp_path, user_message="Quote XAUUSD")
+ ledger.ingest_tool_result(tool_name="search_symbol", arguments={"query": "XAUUSD"}, result=result, call_id="ambiguous-mt5", success=True)
+ assert ledger.identity_status != "locked"
+ assert ledger.identity_summary()["authorized_symbols"] == []