fix: complete September 28 reliability reviews and broker documentation

Bound BaoStock messages and serialize SDK sessions; verify Copilot credential
lookup expiry without changing the SDK's per-call credential resolution.
Run public-loader health checks in isolated, credential-free processes and a
separate dependency environment, with explicit failure reports and a weekly
workflow. Render broker capabilities per profile and document verification
requirements. Update September 28 News in all seven README languages.

Closes #1626
Closes #1623
Closes #1617
Closes #1492
This commit is contained in:
Haozhe Wu committed 2026-09-28 16:07:06 +08:00
1 parent 3562ac7696
commit dec2c0e6b8
18 files changed
+1151 -202

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
name: Public loader health
on:
schedule:
- cron: '17 4 * * 1'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: public-loader-health
cancel-in-progress: false
jobs:
canary:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
cache: pip
- name: Install isolated public-source dependencies
# mootdx requires an older httpx than the application; do not install
# these optional SDKs into the application's dependency environment.
run: |
python -m venv "$RUNNER_TEMP/loader-health-venv"
"$RUNNER_TEMP/loader-health-venv/bin/python" -m pip install -r tools/requirements-loader-health.txt
- name: Probe every public source in isolated processes
working-directory: agent
run: '"$RUNNER_TEMP/loader-health-venv/bin/python" -m backtest.loader_health --output "$RUNNER_TEMP/loader-health.json"'
- name: Retain sanitized health report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: public-loader-health
path: ${{ runner.temp }}/loader-health.json
if-no-files-found: warn
retention-days: 30
+62
View File
@@ -163,3 +163,65 @@ trailer; keep commit metadata clean.
By contributing, you agree that your contributions are licensed under the
project's MIT license (see `LICENSE`).
## Broker Bring-up Checklist
Apply this checklist to every new connector or capability change. A generated
matrix row records a declaration; attach separate evidence for runtime claims.
- [ ] Declare each paper/live and read-only/trading profile separately in the
connector's `profiles.py`. Do not transfer a paper permission into a live
profile. Document sandbox versus local simulation and the structural runtime
discriminator; without one, live placement must remain disabled.
- [ ] Declare only mapped capabilities. Record the actual read/quote endpoints,
response shapes, currency, pagination, and unsupported asset coverage, with
sanitized fixtures and source references. A listed quote capability is not
proof that every position can be priced.
- [ ] Record supported order kinds and instrument classes from observed schemas
or broker documentation; they are not inferred by the matrix. Test unsupported
requests and malformed or incomplete replies fail closed.
- [ ] Test live risk-increasing actions through the shared mandate gate,
including account selection, limits, kill switch, and audit. Verify cancel,
flatten, position management, and copy paths separately where implemented;
placement coverage alone does not establish their coverage.
- [ ] Separate offline contract tests from authorized sandbox/live verification.
State exactly which paths were exercised, when, and which remain unverified.
Never commit credentials, account records, or unsanitized broker responses.
- [ ] Regenerate the README from the repository root and run the CI drift guard:
```bash
PYTHONPATH=agent python -m src.trading.capability_matrix
pytest agent/tests/test_capability_matrix.py -q
```
Include the generated profile rows and verification evidence in the same PR.
### Public-source health lane
The **Public loader health** workflow runs weekly (Monday 04:17 UTC) and can
be dispatched manually. It is separate from offline PR tests. To reproduce:
```bash
python3.11 -m venv /tmp/vibe-loader-health-venv
/tmp/vibe-loader-health-venv/bin/python -m pip install -r tools/requirements-loader-health.txt
cd agent
/tmp/vibe-loader-health-venv/bin/python -m backtest.loader_health --output /tmp/loader-health.json
```
Use the isolated environment: mootdx's HTTP client requirement conflicts with
the application's dependency range. The canary imports the checkout's loader
code directly, without installing the full application dependency set.
Each unauthenticated network loader has a liquid canary symbol; the local-file
loader is explicitly excluded. Adding a public loader requires updating the
canary catalog, enforced by the offline test suite. Probes bypass the loader
cache and run with a temporary home and no inherited credentials. Each source
has a 120-second total subprocess deadline, including two attempts, and the
workflow has its own job deadline. Reports contain only source identifiers and
validation metadata, never returned bars, raw exceptions, or account settings.
Missing dependencies, unavailable endpoints, connection failures, malformed
OHLCV, and bars older than 14 days fail the health lane; none become passing
skips. A failed lane is an investigation signal, not proof the provider itself
is broken: runner geography, holidays and upstream changes need checking.
The 14-day window is a coarse freshness alarm, not a market-calendar guarantee.
Reports are retained for 30 days. No authenticated source or broker order path
is exercised.
+71 -24
View File
@@ -52,6 +52,8 @@
> ⚠️ **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-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)).
- **2026-09-26** 🛠️ **More reliable indicators and trading limits**: Technical indicators retain their observation date and can reuse results lost during conversation compaction ([#1590](https://github.com/HKUDS/Vibe-Trading/pull/1590), [#1601](https://github.com/HKUDS/Vibe-Trading/pull/1601)). All five Qlib158 WVMA windows now use absolute returns in the numerator ([#1594](https://github.com/HKUDS/Vibe-Trading/pull/1594)); Benford checks preserve the correct leading digit at numeric boundaries ([#1591](https://github.com/HKUDS/Vibe-Trading/pull/1591)). An explicitly zero exposure limit stays zero ([#1593](https://github.com/HKUDS/Vibe-Trading/pull/1593)), and Dhan paper orders reject fractional or invalid quantities without rounding away input precision ([#1595](https://github.com/HKUDS/Vibe-Trading/pull/1595)). Invalid optional numeric environment variables fall back to defaults while explicit configuration constraints remain enforced ([#1592](https://github.com/HKUDS/Vibe-Trading/pull/1592)). Runtime triggers keep the correct field defaults and subclass factory behavior ([#1599](https://github.com/HKUDS/Vibe-Trading/pull/1599)).
@@ -420,32 +422,77 @@ Built-in SDK connectors publish one machine-readable onboarding contract: authen
<!-- BEGIN GENERATED broker-capability-matrix -->
Which read and write paths each built-in connector actually exposes,
generated from the profile registry (`agent/src/trading/profiles.py`). An
order-capable profile still goes through the mandate gate: placement on live
funds is authorized only with a mandate in place.
Declared built-in profiles, generated from `agent/src/trading/profiles.py`.
Each row keeps its own environment and permissions; a paper order capability
never grants live trading. These are declarations, not successful runtime or
broker verification. Local plugins and user connection settings are excluded.
| Connector | Environments | Modes | Read paths | Order paths |
| --- | --- | --- | --- | --- |
The quote column shows the declared transport, not a verified endpoint or
pricing coverage. Order kinds (market, limit, etc.) are not declared in the
registry and are therefore not inferred here. The placement column reports
the declared mandate requirement only; it does not attest to cancellation,
flattening, copy-trading, or other runtime guard coverage. Paper placement can
use a broker sandbox or local simulation. See each profile's notes and the
[broker bring-up checklist](CONTRIBUTING.md#broker-bring-up-checklist).
| alpaca | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.place.requires_mandate`, `orders.read` |
| binance | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.place.requires_mandate`, `orders.read` |
| dhan | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.read` |
| etoro | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `copy.close`, `copy.poll`, `copy.precheck`, `copy.start`, `orders.cancel`, `orders.cancel_close`, `orders.place`, `orders.place.requires_mandate`, `orders.read` |
| futu | live/paper | read + order-capable | `acc_cash_flow.read`, `account.read`, `capital_distribution.read`, `capital_flow.read`, `earnings_calendar.read`, `financials.read`, `history.read`, `history_deals.read`, `orders.read`, `positions.read`, `quotes.read`, `rehab.read` | `orders.place`, `orders.place.requires_mandate`, `orders.read` |
| ibkr | live/paper | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.read` |
| kis | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.read` |
| longbridge | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.read` |
| mt5 | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.place.requires_mandate`, `orders.read` |
| okx | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.place.requires_mandate`, `orders.read` |
| robinhood | live | read + order-capable | `account.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place.requires_mandate`, `orders.read` |
| scalable | live | read-only | `quotes.read` | none |
| shoonya | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.read` |
| tiger | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.place.requires_mandate`, `orders.read` |
| toss | live | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.read` |
| trading212 | live/paper | read-only | `account.read`, `instruments.read`, `order_history.read`, `orders.read`, `positions.read` | `orders.read` |
| upbit | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.read` |
| zerodha | live/paper | read + order-capable | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | `orders.place`, `orders.read` |
| Profile | Connector | Environment | Transport | Mode | Read capabilities | Quote path (declared) | Other capabilities | Placement requirement (declared) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| `alpaca-live-sdk-readonly` | alpaca | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `alpaca-live-trade` | alpaca | live | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place.requires_mandate` | mandate required |
| `alpaca-paper-sdk` | alpaca | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `alpaca-paper-trade` | alpaca | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `binance-live-sdk-readonly` | binance | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `binance-live-trade` | binance | live | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place.requires_mandate` | mandate required |
| `binance-paper-sdk` | binance | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `binance-paper-trade` | binance | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `dhan-live-sdk-readonly` | dhan | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `dhan-paper-sdk` | dhan | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `dhan-paper-trade` | dhan | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `etoro-live-sdk-readonly` | etoro | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `etoro-live-trade` | etoro | live | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `copy.close`, `copy.poll`, `copy.precheck`, `copy.start`, `orders.cancel`, `orders.cancel_close`, `orders.place.requires_mandate`, `positions.close`, `positions.edit` | mandate required |
| `etoro-paper-sdk` | etoro | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `etoro-paper-trade` | etoro | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.cancel`, `orders.cancel_close`, `orders.place`, `positions.close`, `positions.edit` | no mandate declared |
| `futu-live-sdk-readonly` | futu | live | broker_sdk | read-only | `acc_cash_flow.read`, `account.read`, `capital_distribution.read`, `capital_flow.read`, `earnings_calendar.read`, `financials.read`, `history.read`, `history_deals.read`, `orders.read`, `positions.read`, `quotes.read`, `rehab.read` | broker_sdk | none declared | disabled (read-only) |
| `futu-live-trade` | futu | live | broker_sdk | write-enabled | `acc_cash_flow.read`, `account.read`, `capital_distribution.read`, `capital_flow.read`, `earnings_calendar.read`, `financials.read`, `history.read`, `history_deals.read`, `orders.read`, `positions.read`, `quotes.read`, `rehab.read` | broker_sdk | `orders.place.requires_mandate` | mandate required |
| `futu-paper-sdk` | futu | paper | broker_sdk | read-only | `acc_cash_flow.read`, `account.read`, `capital_distribution.read`, `capital_flow.read`, `earnings_calendar.read`, `financials.read`, `history.read`, `history_deals.read`, `orders.read`, `positions.read`, `quotes.read`, `rehab.read` | broker_sdk | none declared | disabled (read-only) |
| `futu-paper-trade` | futu | paper | broker_sdk | write-enabled | `acc_cash_flow.read`, `account.read`, `capital_distribution.read`, `capital_flow.read`, `earnings_calendar.read`, `financials.read`, `history.read`, `history_deals.read`, `orders.read`, `positions.read`, `quotes.read`, `rehab.read` | broker_sdk | `orders.place` | no mandate declared |
| `ibkr-live-local-readonly` | ibkr | live | local_tws | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | local_tws | none declared | disabled (read-only) |
| `ibkr-live-official-mcp-readonly` | ibkr | live | remote_mcp | read-only | `account.read`, `positions.read` | none declared | none declared | disabled (read-only) |
| `ibkr-paper-local` | ibkr | paper | local_tws | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | local_tws | none declared | disabled (read-only) |
| `kis-live-sdk-readonly` | kis | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `kis-paper-sdk` | kis | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `kis-paper-trade` | kis | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `longbridge-live-sdk-readonly` | longbridge | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `longbridge-paper-sdk` | longbridge | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `longbridge-paper-trade` | longbridge | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `mt5-live-sdk-readonly` | mt5 | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `mt5-live-trade` | mt5 | live | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place.requires_mandate` | mandate required |
| `mt5-paper-sdk` | mt5 | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `mt5-paper-trade` | mt5 | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `okx-live-sdk-readonly` | okx | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `okx-live-trade` | okx | live | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place.requires_mandate` | mandate required |
| `okx-paper-sdk` | okx | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `okx-paper-trade` | okx | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `robinhood-live-mcp` | robinhood | live | remote_mcp | write-enabled | `account.read`, `orders.read`, `positions.read`, `quotes.read` | remote_mcp | `orders.place.requires_mandate`, `runner.manage.requires_mandate` | mandate required |
| `robinhood-live-mcp-readonly` | robinhood | live | remote_mcp | read-only | `account.read`, `positions.read` | none declared | none declared | disabled (read-only) |
| `scalable-live-mcp-readonly` | scalable | live | remote_mcp | read-only | `quotes.read` | remote_mcp | none declared | disabled (read-only) |
| `shoonya-live-sdk-readonly` | shoonya | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `shoonya-paper-sdk` | shoonya | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `shoonya-paper-trade` | shoonya | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `tiger-live-sdk-readonly` | tiger | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `tiger-live-trade` | tiger | live | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place.requires_mandate` | mandate required |
| `tiger-paper-sdk` | tiger | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `tiger-paper-trade` | tiger | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `toss-live-sdk-readonly` | toss | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `trading212-live-sdk-readonly` | trading212 | live | broker_sdk | read-only | `account.read`, `instruments.read`, `order_history.read`, `orders.read`, `positions.read` | none declared | none declared | disabled (read-only) |
| `trading212-paper-sdk` | trading212 | paper | broker_sdk | read-only | `account.read`, `instruments.read`, `order_history.read`, `orders.read`, `positions.read` | none declared | none declared | disabled (read-only) |
| `upbit-live-sdk-readonly` | upbit | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `upbit-paper-sdk` | upbit | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `upbit-paper-trade` | upbit | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
| `zerodha-live-sdk-readonly` | zerodha | live | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `zerodha-paper-sdk` | zerodha | paper | broker_sdk | read-only | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | none declared | disabled (read-only) |
| `zerodha-paper-trade` | zerodha | paper | broker_sdk | write-enabled | `account.read`, `history.read`, `orders.read`, `positions.read`, `quotes.read` | broker_sdk | `orders.place` | no mandate declared |
<!-- END GENERATED broker-capability-matrix -->
+2
View File
@@ -52,6 +52,8 @@
> ⚠️ **تحذير أمني:** حساب 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-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)).
- **2026-09-26** 🛠️ **مؤشرات وحدود تداول أكثر موثوقية**: تحتفظ المؤشرات الفنية بتاريخ الرصد ويمكنها إعادة استخدام النتائج التي أزيلت أثناء ضغط المحادثة ([#1590](https://github.com/HKUDS/Vibe-Trading/pull/1590)، [#1601](https://github.com/HKUDS/Vibe-Trading/pull/1601)). تستخدم نوافذ WVMA الخمس في Qlib158 العوائد المطلقة في البسط ([#1594](https://github.com/HKUDS/Vibe-Trading/pull/1594))، ويحافظ فحص Benford على الرقم الأول الصحيح عند الحدود العددية ([#1591](https://github.com/HKUDS/Vibe-Trading/pull/1591)). يبقى حد التعرض المحدد صراحةً بصفر صفراً ([#1593](https://github.com/HKUDS/Vibe-Trading/pull/1593))، وترفض أوامر Dhan التجريبية الكميات الكسرية أو غير الصالحة دون فقدان دقة المدخلات بسبب التقريب ([#1595](https://github.com/HKUDS/Vibe-Trading/pull/1595)). تعود متغيرات البيئة العددية الاختيارية غير الصالحة إلى القيم الافتراضية مع استمرار تطبيق قيود الإعدادات الصريحة ([#1592](https://github.com/HKUDS/Vibe-Trading/pull/1592)). كما أُصلحت القيم الافتراضية لحقول مشغلات وقت التشغيل وسلوك إنشاء مثيلاتها في الأصناف الفرعية ([#1599](https://github.com/HKUDS/Vibe-Trading/pull/1599)).
+2
View File
@@ -52,6 +52,8 @@
> ⚠️ **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-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)).
- **2026-09-26** 🛠️ **Indicadores y límites de negociación más fiables**: Los indicadores técnicos conservan la fecha de observación y pueden reutilizar resultados eliminados al compactar la conversación ([#1590](https://github.com/HKUDS/Vibe-Trading/pull/1590), [#1601](https://github.com/HKUDS/Vibe-Trading/pull/1601)). Las cinco ventanas WVMA de Qlib158 usan rendimientos absolutos en el numerador ([#1594](https://github.com/HKUDS/Vibe-Trading/pull/1594)); la comprobación de Benford conserva el primer dígito correcto en los límites numéricos ([#1591](https://github.com/HKUDS/Vibe-Trading/pull/1591)). Un límite de exposición fijado explícitamente en cero permanece en cero ([#1593](https://github.com/HKUDS/Vibe-Trading/pull/1593)), y las órdenes simuladas de Dhan rechazan cantidades fraccionarias o inválidas sin perder precisión por redondeo ([#1595](https://github.com/HKUDS/Vibe-Trading/pull/1595)). Las variables de entorno numéricas opcionales inválidas vuelven a sus valores predeterminados, manteniendo las restricciones de la configuración explícita ([#1592](https://github.com/HKUDS/Vibe-Trading/pull/1592)). Los activadores de ejecución conservan los valores predeterminados de sus campos y el comportamiento correcto de sus métodos de creación en subclases ([#1599](https://github.com/HKUDS/Vibe-Trading/pull/1599)).
+2
View File
@@ -53,6 +53,8 @@
> ⚠️ **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-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)).
- **2026-09-26** 🛠️ **Indikator dan batas perdagangan lebih andal**: Indikator teknis mempertahankan tanggal pengamatan dan dapat memakai kembali hasil yang terhapus saat percakapan dipadatkan ([#1590](https://github.com/HKUDS/Vibe-Trading/pull/1590), [#1601](https://github.com/HKUDS/Vibe-Trading/pull/1601)). Kelima jendela WVMA Qlib158 memakai imbal hasil absolut pada pembilang ([#1594](https://github.com/HKUDS/Vibe-Trading/pull/1594)); pemeriksaan Benford mempertahankan digit pertama yang benar pada batas numerik ([#1591](https://github.com/HKUDS/Vibe-Trading/pull/1591)). Batas eksposur yang secara eksplisit ditetapkan nol tetap nol ([#1593](https://github.com/HKUDS/Vibe-Trading/pull/1593)), dan pesanan simulasi Dhan menolak jumlah pecahan atau tidak valid tanpa kehilangan presisi akibat pembulatan ([#1595](https://github.com/HKUDS/Vibe-Trading/pull/1595)). Variabel lingkungan numerik opsional yang tidak valid kembali ke nilai bawaan, sementara batasan konfigurasi eksplisit tetap berlaku ([#1592](https://github.com/HKUDS/Vibe-Trading/pull/1592)). Pemicu runtime juga mempertahankan nilai bawaan kolom dan perilaku metode pembuat objek pada subkelas dengan benar ([#1599](https://github.com/HKUDS/Vibe-Trading/pull/1599)).
+2
View File
@@ -52,6 +52,8 @@
> ⚠️ **セキュリティ警告:** 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-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))。
- **2026-09-26** 🛠️ **指標計算と取引上限の信頼性を改善**:テクニカル指標は観測日を保持し、会話の圧縮で失われた結果を再利用できます([#1590](https://github.com/HKUDS/Vibe-Trading/pull/1590)、[#1601](https://github.com/HKUDS/Vibe-Trading/pull/1601))。Qlib158 の全5種類の WVMA 窓で分子に絶対リターンを使用し([#1594](https://github.com/HKUDS/Vibe-Trading/pull/1594))、Benford 検査は数値の境界でも正しい先頭桁を保持します([#1591](https://github.com/HKUDS/Vibe-Trading/pull/1591))。明示的にゼロに設定したエクスポージャー上限はゼロのまま維持され([#1593](https://github.com/HKUDS/Vibe-Trading/pull/1593))、Dhan の模擬注文は入力精度を丸めず、小数や無効な数量を拒否します([#1595](https://github.com/HKUDS/Vibe-Trading/pull/1595))。任意の数値環境変数が無効な場合はデフォルト値に戻り、明示的な設定の制約は引き続き適用されます([#1592](https://github.com/HKUDS/Vibe-Trading/pull/1592))。実行時トリガーのフィールド初期値とサブクラスのファクトリ動作も修正しました([#1599](https://github.com/HKUDS/Vibe-Trading/pull/1599))。
+2
View File
@@ -52,6 +52,8 @@
> ⚠️ **보안 경고:** 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-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)).
- **2026-09-26** 🛠️ **지표 계산과 거래 한도 개선**: 기술적 지표는 관측 날짜를 유지하고 대화 압축으로 사라진 결과를 재사용할 수 있습니다([#1590](https://github.com/HKUDS/Vibe-Trading/pull/1590), [#1601](https://github.com/HKUDS/Vibe-Trading/pull/1601)). Qlib158의 다섯 WVMA 기간 모두 분자에 절대 수익률을 사용하며([#1594](https://github.com/HKUDS/Vibe-Trading/pull/1594)), Benford 검사는 수치 경계에서도 올바른 첫 자릿수를 유지합니다([#1591](https://github.com/HKUDS/Vibe-Trading/pull/1591)). 명시적으로 0으로 설정한 익스포저 한도는 그대로 유지되고([#1593](https://github.com/HKUDS/Vibe-Trading/pull/1593)), Dhan 모의 주문은 입력 정밀도를 반올림으로 잃지 않으면서 소수 및 잘못된 수량을 거부합니다([#1595](https://github.com/HKUDS/Vibe-Trading/pull/1595)). 잘못된 선택적 숫자 환경 변수는 기본값으로 돌아가며 명시적 설정의 제약은 계속 적용됩니다([#1592](https://github.com/HKUDS/Vibe-Trading/pull/1592)). 런타임 트리거의 필드 기본값과 하위 클래스 팩터리 동작도 수정했습니다([#1599](https://github.com/HKUDS/Vibe-Trading/pull/1599)).
+2
View File
@@ -52,6 +52,8 @@
> ⚠️ **安全警告:** 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-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))。
- **2026-09-26** 🛠️ **指标计算与交易限额更可靠**:技术指标保留观测日期,并能恢复对话压缩时移除的结果,避免重复取数([#1590](https://github.com/HKUDS/Vibe-Trading/pull/1590)、[#1601](https://github.com/HKUDS/Vibe-Trading/pull/1601))。Qlib158 的五个 WVMA 窗口统一使用绝对收益率计算分子([#1594](https://github.com/HKUDS/Vibe-Trading/pull/1594));Benford 检查在数值边界也能保留正确首位数字([#1591](https://github.com/HKUDS/Vibe-Trading/pull/1591))。明确设为零的敞口上限保持为零([#1593](https://github.com/HKUDS/Vibe-Trading/pull/1593)),Dhan 模拟订单拒绝小数及无效数量,不再因舍入丢失输入精度([#1595](https://github.com/HKUDS/Vibe-Trading/pull/1595))。无效的可选数值环境变量回退到默认值,显式配置约束仍然生效([#1592](https://github.com/HKUDS/Vibe-Trading/pull/1592))。运行时触发器修复字段默认值,并保留子类工厂的正确行为([#1599](https://github.com/HKUDS/Vibe-Trading/pull/1599))。
+4
View File
@@ -255,6 +255,10 @@ TUSHARE_TOKEN=your-tushare-token
# re-fetched). Clear it any time with: rm -rf ~/.vibe-trading/cache
# VIBE_TRADING_DATA_CACHE=1
# BaoStock deadline in seconds per lock wait, connection and complete message.
# Does not bound the combined duration of a multi-symbol fetch.
# VIBE_TRADING_BAOSTOCK_TIMEOUT=30
# Path to a published Taiwan-market SQLite snapshot. The read-only Taiwan stock
# tool registers only when this points at a database with the expected schema,
# so leaving it unset simply omits the tool. Keep the file outside the checkout —
+249
View File
@@ -0,0 +1,249 @@
"""Public-source health lane: isolated processes, no keys/cache, safe reports.
Run ``python -m backtest.loader_health --output report.json`` from agent/.
Every non-healthy source fails the lane; connectivity is never a passing skip.
"""
from __future__ import annotations
import argparse
from concurrent.futures import ThreadPoolExecutor
from contextlib import redirect_stderr, redirect_stdout
from datetime import date, datetime, timedelta, timezone
import importlib.util
import json
import math
import os
from pathlib import Path
import subprocess
import sys
import tempfile
CANARY_SYMBOLS = {
"akshare": "601398.SH",
"baostock": "sh.601398",
"binance": "BTC-USDT",
"ccxt": "BTC-USDT",
"eastmoney": "601398.SH",
"mootdx": "601398.SH",
"nobitex": "BTC-IRT",
"okx": "BTC-USDT",
"pykrx": "005930.KS",
"sina": "AAPL.US",
"stooq": "AAPL.US",
"tencent": "601398.SH",
"wallex": "BTC-TMN",
"yahoo": "AAPL",
"yfinance": "AAPL",
}
EXCLUDED_PUBLIC_SOURCES = {"local": "operator files, not a public endpoint"}
DEPENDENCIES = {
"akshare": "akshare",
"baostock": "baostock",
"ccxt": "ccxt",
"mootdx": "mootdx",
"pykrx": "pykrx",
"yfinance": "yfinance",
}
FRESHNESS_DAYS = 14
WINDOW_DAYS = 21
def coverage_errors() -> list[str]:
"""Return catalog drift without probing network or credentials."""
from backtest.loaders.registry import LOADER_REGISTRY, VALID_SOURCES, _ensure_registered
_ensure_registered()
missing = VALID_SOURCES - {"auto"} - LOADER_REGISTRY.keys()
public = {name for name, cls in LOADER_REGISTRY.items() if not cls.requires_auth}
mapped = CANARY_SYMBOLS.keys() | EXCLUDED_PUBLIC_SOURCES.keys()
return [
*[f"unregistered:{n}" for n in sorted(missing)],
*[f"unmapped:{n}" for n in sorted(public - mapped)],
*[f"unexpected:{n}" for n in sorted(mapped - public)],
]
def check_frame(frame, today: date) -> dict:
"""Validate normalized OHLCV and return safe diagnostic metadata.
Args:
frame: A single source's candidate DataFrame.
today: UTC date used to judge freshness.
Returns:
Fixed status/reason and, on success, row count and last bar date.
"""
import numpy as np
import pandas as pd
def bad(reason):
return {"status": "invalid", "reason": reason}
if not isinstance(frame, pd.DataFrame) or frame.empty:
return bad("empty_frame")
columns = ["open", "high", "low", "close", "volume"]
if not frame.columns.is_unique or not set(columns) <= set(frame.columns):
return bad("schema")
if not isinstance(frame.index, pd.DatetimeIndex) or frame.index.hasnans:
return bad("datetime_index")
if not frame.index.is_monotonic_increasing or not frame.index.is_unique:
return bad("index_order")
if any(not pd.api.types.is_numeric_dtype(frame[c]) for c in columns):
return bad("numeric_columns")
values = frame[columns].to_numpy(dtype=float)
if not np.isfinite(values).all():
return bad("nonfinite_values")
if (values[:, :4] <= 0).any() or (values[:, 4] < 0).any():
return bad("invalid_prices_or_volume")
if (frame.high < frame[["open", "close", "low"]].max(axis=1)).any() or (
frame.low > frame[["open", "close"]].min(axis=1)
).any():
return bad("ohlc_order")
last_date = frame.index[-1].date()
age = (today - last_date).days
if age < 0:
return bad("future_bar")
if age > FRESHNESS_DAYS:
return {"status": "stale", "reason": "last_bar_too_old", "age_days": age}
return {"status": "healthy", "rows": len(frame), "last_bar": last_date.isoformat(), "age_days": age}
def probe(source: str, today: date) -> dict:
"""Fetch an explicit public loader; never fall back to another source."""
import requests
from backtest.loaders.registry import LOADER_REGISTRY, _ensure_registered
dependency = DEPENDENCIES.get(source)
if dependency and importlib.util.find_spec(dependency) is None:
return {"status": "missing_dependency", "reason": "install_canary_dependencies"}
_ensure_registered()
cls = LOADER_REGISTRY.get(source)
if cls is None:
return {"status": "unavailable", "reason": "loader_not_registered"}
if cls.requires_auth or source not in CANARY_SYMBOLS:
return {"status": "invalid", "reason": "not_public_canary"}
symbol = CANARY_SYMBOLS[source]
result = {"status": "unavailable", "reason": "availability_probe_failed"}
for attempt in range(1, 3):
try:
loader = cls()
if not loader.is_available():
result = {"status": "unavailable", "reason": "availability_probe_failed"}
else:
frames = loader.fetch(
[symbol], (today - timedelta(days=WINDOW_DAYS)).isoformat(), today.isoformat(), interval="1D"
)
result = check_frame(frames.get(symbol) if isinstance(frames, dict) else None, today)
except (ConnectionError, TimeoutError, requests.exceptions.ConnectionError, requests.exceptions.Timeout):
result = {"status": "unreachable", "reason": "network_error"}
except ImportError:
result = {"status": "missing_dependency", "reason": "loader_import_failed"}
except Exception:
# Exception messages can contain URLs, local paths or credentials.
result = {"status": "error", "reason": "fetch_failed"}
result["attempts"] = attempt
if result["status"] == "healthy":
break
return result
def child_environment(home: str) -> dict[str, str]:
"""Allow process essentials only, excluding operator keys and settings."""
essentials = {"PATH", "SYSTEMROOT", "WINDIR", "LANG", "LC_ALL", "SSL_CERT_FILE", "SSL_CERT_DIR"}
env = {k: v for k, v in os.environ.items() if k.upper() in essentials}
env.update(
{
"HOME": home,
"USERPROFILE": home,
"VIBE_TRADING_HOME": home,
"VIBE_TRADING_DATA_CACHE": "0",
"PYTHONPATH": str(Path(__file__).resolve().parents[1]),
"PYTHONIOENCODING": "utf-8",
"PYTHONNOUSERSITE": "1",
}
)
return env
def run_source(source: str, today: date, timeout: float) -> dict:
"""Isolate one source and bound imports, availability probes and retries."""
row = {"source": source, "symbol": CANARY_SYMBOLS[source]}
with tempfile.TemporaryDirectory(prefix="vibe-loader-health-") as home:
output = Path(home) / "result.json"
try:
completed = subprocess.run(
[
sys.executable,
"-m",
"backtest.loader_health",
"--source",
source,
"--today",
today.isoformat(),
"--output",
str(output),
],
cwd=home,
env=child_environment(home),
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=timeout,
check=False,
)
if completed.returncode != 0:
return row | {"status": "error", "reason": "child_failed"}
result = json.loads(output.read_text(encoding="utf-8"))
if not isinstance(result, dict) or result.get("status") not in {
"healthy",
"invalid",
"stale",
"missing_dependency",
"unavailable",
"unreachable",
"error",
}:
return row | {"status": "error", "reason": "child_report_failed"}
return row | result
except subprocess.TimeoutExpired:
return row | {"status": "timeout", "reason": "source_deadline"}
except (OSError, ValueError):
return row | {"status": "error", "reason": "child_report_failed"}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--timeout", type=float, default=120)
parser.add_argument("--workers", type=int, default=3)
parser.add_argument("--source", choices=sorted(CANARY_SYMBOLS), help=argparse.SUPPRESS)
parser.add_argument("--today", type=date.fromisoformat, help=argparse.SUPPRESS)
args = parser.parse_args()
if not math.isfinite(args.timeout) or args.timeout <= 0 or not 1 <= args.workers <= 4:
parser.error("timeout must be finite and positive; workers must be 1..4")
today = args.today or datetime.now(timezone.utc).date()
if args.source:
with open(os.devnull, "w") as sink, redirect_stdout(sink), redirect_stderr(sink):
report = probe(args.source, today)
exit_code = 0
else:
errors = coverage_errors()
with ThreadPoolExecutor(max_workers=args.workers) as pool:
rows = list(pool.map(lambda source: run_source(source, today, args.timeout), sorted(CANARY_SYMBOLS)))
report = {
"date": today.isoformat(),
"coverage_errors": errors,
"excluded": EXCLUDED_PUBLIC_SOURCES,
"sources": rows,
}
exit_code = int(bool(errors) or any(row["status"] != "healthy" for row in rows))
for row in rows:
print(f"{row['source']}: {row['status']}")
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
return exit_code
if __name__ == "__main__":
raise SystemExit(main())
+49 -33
View File
@@ -10,8 +10,11 @@ Covers: A-shares (SH/SZ), does NOT cover HK/US/crypto.
from __future__ import annotations
import logging
import math
import os
import socket
import threading
import time
import zlib
from contextlib import contextmanager
from typing import Dict, List, Optional
@@ -23,11 +26,13 @@ from backtest.loaders.registry import register
logger = logging.getLogger(__name__)
# Per-read inactivity deadline for baostock socket IO (#1492). baostock 0.9.3
# Per-message deadline for baostock socket IO (#1492). baostock 0.9.3
# reads with no timeout and spins at 100% CPU when the peer closes the
# connection, so every loader call goes through the guard below.
_READ_TIMEOUT_ENV = "VIBE_TRADING_BAOSTOCK_TIMEOUT"
_DEFAULT_READ_TIMEOUT = 30.0
# BaoStock owns one process-global socket and user context, even across instances.
_BAOSTOCK_LOCK = threading.Lock()
def _read_timeout() -> float:
@@ -35,7 +40,7 @@ def _read_timeout() -> float:
if raw:
try:
value = float(raw)
if value > 0:
if math.isfinite(value) and value > 0:
return value
except ValueError:
pass
@@ -44,7 +49,7 @@ def _read_timeout() -> float:
def _bounded_send_msg(msg: str, timeout: float) -> Optional[str]:
"""baostock's send_msg with a read deadline and EOF detection.
"""baostock's send_msg with a whole-message deadline and EOF detection.
Byte-compatible with baostock 0.9.3 on a healthy server. Returns None on
timeout, closed connection, or a malformed reply; every baostock caller
@@ -60,37 +65,41 @@ def _bounded_send_msg(msg: str, timeout: float) -> Optional[str]:
return None
try:
previous_timeout = default_socket.gettimeout()
deadline = time.monotonic() + timeout
default_socket.settimeout(timeout)
try:
default_socket.send(bytes(msg + "\n", encoding="utf-8"))
receive = b""
default_socket.sendall(bytes(msg + "\n", encoding="utf-8"))
receive = bytearray()
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("baostock message deadline exceeded")
default_socket.settimeout(remaining)
chunk = default_socket.recv(8192)
if not chunk:
logger.warning("baostock server closed the connection mid-reply")
return None
raise ConnectionError("baostock server closed the connection mid-reply")
receive += chunk
if receive[-13:] == b"<![CDATA[]]>\n":
break
finally:
default_socket.settimeout(previous_timeout)
receive = bytes(receive)
head_bytes = receive[0 : cons.MESSAGE_HEADER_LENGTH]
head_str = bytes.decode(head_bytes)
head_arr = head_str.split(cons.MESSAGE_SPLIT)
if head_arr[1] in cons.COMPRESSED_MESSAGE_TYPE_TUPLE:
body_length = int(head_arr[2])
body_str = bytes.decode(
zlib.decompress(
receive[
cons.MESSAGE_HEADER_LENGTH : cons.MESSAGE_HEADER_LENGTH
+ body_length
]
)
zlib.decompress(receive[cons.MESSAGE_HEADER_LENGTH : cons.MESSAGE_HEADER_LENGTH + body_length])
)
return head_str + body_str
return bytes.decode(receive)
except Exception as exc:
logger.warning("baostock read failed: %s", exc)
# A late or partial reply must never be consumed by a subsequent query.
default_socket.close()
if getattr(context, "default_socket", None) is default_socket:
context.default_socket = None
return None
@@ -100,9 +109,7 @@ def _connect_with_timeout(self, timeout: float) -> None:
import baostock.common.context as context
try:
sock = socket.create_connection(
(cons.BAOSTOCK_SERVER_IP, cons.BAOSTOCK_SERVER_PORT), timeout=timeout
)
sock = socket.create_connection((cons.BAOSTOCK_SERVER_IP, cons.BAOSTOCK_SERVER_PORT), timeout=timeout)
except OSError as exc:
logger.warning("baostock connect failed: %s", exc)
sock = None
@@ -114,25 +121,38 @@ def _baostock_socket_guard(timeout: float):
"""Bound all baostock socket IO for the lifetime of one fetch."""
import baostock
# Attribute chain, not a submodule import, so sys.modules mocks still work.
if not _BAOSTOCK_LOCK.acquire(timeout=timeout):
logger.warning("baostock session busy; falling back")
yield False
return
# Serialize the entire login/query/logout session, not just the patch itself.
# Attribute chains also keep the existing sys.modules test doubles usable.
socketutil = baostock.util.socketutil
context = baostock.common.context
original_send_msg = socketutil.send_msg
original_connect = socketutil.SocketUtil.connect
previous_socket = getattr(context, "default_socket", None)
context.default_socket = None
socketutil.send_msg = lambda msg: _bounded_send_msg(msg, timeout)
socketutil.SocketUtil.connect = lambda util: _connect_with_timeout(util, timeout)
try:
yield
yield True
finally:
socketutil.send_msg = original_send_msg
socketutil.SocketUtil.connect = original_connect
try:
current_socket = getattr(context, "default_socket", None)
if current_socket is not None:
current_socket.close()
finally:
context.default_socket = previous_socket
socketutil.send_msg = original_send_msg
socketutil.SocketUtil.connect = original_connect
_BAOSTOCK_LOCK.release()
def _is_a_share(code: str) -> bool:
# Support both baostock native format (sh.601398) and tushare-style suffix (601398.SH)
code_lower = code.lower()
return code_lower.startswith(("sh.", "sz.")) or code.upper().endswith(
(".SZ", ".SH")
)
return code_lower.startswith(("sh.", "sz.")) or code.upper().endswith((".SZ", ".SH"))
@register
@@ -192,7 +212,9 @@ class DataLoader:
import baostock as bs
with _baostock_socket_guard(_read_timeout()):
with _baostock_socket_guard(_read_timeout()) as acquired:
if not acquired:
return {}
lg = bs.login()
if lg.error_code != "0":
logger.error("baostock login failed: %s", lg.error_msg)
@@ -209,9 +231,7 @@ class DataLoader:
start_date=start_date,
end_date=end_date,
fields=None,
fetch=lambda code=code: self._fetch_one(
bs, code, start_date, end_date
),
fetch=lambda code=code: self._fetch_one(bs, code, start_date, end_date),
)
if df is not None and not df.empty:
result[code] = df
@@ -269,9 +289,7 @@ class DataLoader:
if not rows:
return None
df = pd.DataFrame(
rows, columns=["date", "open", "high", "low", "close", "volume", "amount"]
)
df = pd.DataFrame(rows, columns=["date", "open", "high", "low", "close", "volume", "amount"])
df["date"] = pd.to_datetime(df["date"])
for col in ["open", "high", "low", "close", "volume", "amount"]:
df[col] = pd.to_numeric(df[col], errors="coerce")
@@ -284,7 +302,5 @@ class DataLoader:
df = df.rename(columns={"date": "trade_date"})
df = df.set_index("trade_date").sort_index()
df = df[["open", "high", "low", "close", "volume"]].dropna(
subset=["open", "high", "low", "close"]
)
df = df[["open", "high", "low", "close", "volume"]].dropna(subset=["open", "high", "low", "close"])
return df
+44 -28
View File
@@ -1,14 +1,13 @@
"""Generate the broker capability matrix from the profile registry (#1626).
Which read tools, quote paths and order kinds each connector actually has
lived in per-connector code and tribal knowledge. The profiles already
declare it, so render them into the marked block in README.md and let a test
fail CI the moment the block and the registry disagree, so the table cannot
go stale.
Render declared capabilities per profile into the marked README block.
The registry does not attest to successful runtime verification or enumerate
order kinds; the generated text explicitly preserves those limits. A CI drift
guard keeps the published declarations aligned with the registry.
Regenerate after touching connector profiles:
python -m src.trading.capability_matrix
PYTHONPATH=agent python -m src.trading.capability_matrix
"""
from __future__ import annotations
@@ -22,32 +21,49 @@ README_PATH = Path(__file__).resolve().parents[3] / "README.md"
_BEGIN = "<!-- BEGIN GENERATED broker-capability-matrix -->"
_END = "<!-- END GENERATED broker-capability-matrix -->"
_HEADER = """Which read and write paths each built-in connector actually exposes,
generated from the profile registry (`agent/src/trading/profiles.py`). An
order-capable profile still goes through the mandate gate: placement on live
funds is authorized only with a mandate in place.
_HEADER = """Declared built-in profiles, generated from `agent/src/trading/profiles.py`.
Each row keeps its own environment and permissions; a paper order capability
never grants live trading. These are declarations, not successful runtime or
broker verification. Local plugins and user connection settings are excluded.
| Connector | Environments | Modes | Read paths | Order paths |
| --- | --- | --- | --- | --- |
The quote column shows the declared transport, not a verified endpoint or
pricing coverage. Order kinds (market, limit, etc.) are not declared in the
registry and are therefore not inferred here. The placement column reports
the declared mandate requirement only; it does not attest to cancellation,
flattening, copy-trading, or other runtime guard coverage. Paper placement can
use a broker sandbox or local simulation. See each profile's notes and the
[broker bring-up checklist](CONTRIBUTING.md#broker-bring-up-checklist).
| Profile | Connector | Environment | Transport | Mode | Read capabilities | Quote path (declared) | Other capabilities | Placement requirement (declared) |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
"""
def _cell(values: list[str]) -> str:
return ", ".join(f"`{value}`" for value in sorted(values)) or "none declared"
def render() -> str:
"""One row per connector: envs, modes, and the union of read/order caps."""
"""Render each built-in profile without merging its permissions with others."""
lines = [_HEADER]
by_connector: dict[str, list] = {}
for profile in BUILTIN_PROFILES:
by_connector.setdefault(profile.connector, []).append(profile)
for connector in sorted(by_connector):
profiles = by_connector[connector]
envs = "/".join(sorted({p.environment for p in profiles}))
modes = "read-only" if all(p.readonly for p in profiles) else "read + order-capable"
caps = {c for p in profiles for c in p.capabilities}
reads = sorted(c for c in caps if c.endswith(".read"))
orders = sorted(c for c in caps if c.startswith(("orders.", "copy.")))
read_cell = ", ".join(f"`{c}`" for c in reads) or "none"
order_cell = ", ".join(f"`{c}`" for c in orders) or "none"
lines.append(f"| {connector} | {envs} | {modes} | {read_cell} | {order_cell} |")
for profile in sorted(BUILTIN_PROFILES, key=lambda p: (p.connector, p.id)):
reads = [cap for cap in profile.capabilities if cap.endswith(".read")]
other = [cap for cap in profile.capabilities if not cap.endswith(".read")]
quote = profile.transport if "quotes.read" in profile.capabilities else "none declared"
if profile.readonly:
placement = "disabled (read-only)"
elif "orders.place.requires_mandate" in profile.capabilities:
placement = "mandate required"
elif "orders.place" in profile.capabilities:
placement = "no mandate declared"
else:
placement = "no placement declared"
mode = "read-only" if profile.readonly else "write-enabled"
lines.append(
f"| `{profile.id}` | {profile.connector} | {profile.environment} | "
f"{profile.transport} | {mode} | {_cell(reads)} | {quote} | "
f"{_cell(other)} | {placement} |"
)
return "\n".join(lines) + "\n"
@@ -58,8 +74,8 @@ def render_block() -> str:
def sync_readme(readme: str) -> str:
block = render_block()
pattern = re.compile(re.escape(_BEGIN) + r".*?" + re.escape(_END), re.DOTALL)
if not pattern.search(readme):
raise ValueError(f"README is missing the {_BEGIN} marker block")
if readme.count(_BEGIN) != 1 or readme.count(_END) != 1 or not pattern.search(readme):
raise ValueError(f"README must contain exactly one ordered {_BEGIN} marker block")
return pattern.sub(lambda _: block, readme)
@@ -143,3 +143,231 @@ def test_read_timeout_env(monkeypatch) -> None:
assert _read_timeout() == _DEFAULT_READ_TIMEOUT
monkeypatch.setenv(_READ_TIMEOUT_ENV, "-3")
assert _read_timeout() == _DEFAULT_READ_TIMEOUT
@pytest.mark.parametrize("value", ["inf", "-inf", "nan", "0", "-0.1"])
def test_timeout_must_be_finite_and_positive(monkeypatch, value) -> None:
monkeypatch.setenv(_READ_TIMEOUT_ENV, value)
assert _read_timeout() == _DEFAULT_READ_TIMEOUT
def test_slow_trickle_cannot_extend_message_deadline() -> None:
stopped = threading.Event()
def handler(conn):
conn.recv(4096)
try:
for _ in range(30):
conn.sendall(b"x")
if stopped.wait(0.03):
break
except OSError:
pass
port = _serve_once(handler)
sock = _connect(port)
context.default_socket = sock
try:
started = time.monotonic()
assert _bounded_send_msg("request", timeout=0.15) is None
assert time.monotonic() - started < 0.75
assert context.default_socket is None
assert sock.fileno() == -1
finally:
stopped.set()
def test_compressed_reply_and_full_request_over_real_socket() -> None:
import zlib
body = "0\1success\1测试行情\n"
compressed = zlib.compress(body.encode())
header = (
cons.BAOSTOCK_CLIENT_VERSION + cons.MESSAGE_SPLIT + "96" + cons.MESSAGE_SPLIT + str(len(compressed)).zfill(10)
)
request = "query" * 100_000
received = []
def handler(conn):
data = bytearray()
while not data.endswith(b"\n"):
data.extend(conn.recv(1024))
received.append(bytes(data))
reply = header.encode() + compressed + _TERMINATOR
for offset in range(0, len(reply), 7):
conn.sendall(reply[offset : offset + 7])
port = _serve_once(handler)
sock = _connect(port)
class PartialSendSocket:
"""A real socket with send() capped, as permitted by the socket API."""
def send(self, data):
return sock.send(data[:1])
def __getattr__(self, name):
return getattr(sock, name)
sock.settimeout(0.9)
context.default_socket = PartialSendSocket()
assert _bounded_send_msg(request, timeout=2) == header + body
assert received == [(request + "\n").encode()]
assert sock.gettimeout() == 0.9
def test_guard_serializes_sessions_and_restores_socket_and_patch() -> None:
entered = threading.Event()
release = threading.Event()
second_attempt = threading.Event()
second_entered = threading.Event()
errors = []
socketutil = bs.util.socketutil
original_send = socketutil.send_msg
original_connect = socketutil.SocketUtil.connect
previous, peer = socket.socketpair()
context.default_socket = previous
owned_sockets = []
def run(first):
try:
if not first:
second_attempt.set()
with _baostock_socket_guard(2) as acquired:
assert acquired
assert context.default_socket is None
local, remote = socket.socketpair()
owned_sockets.append(local)
context.default_socket = local
with remote:
if first:
entered.set()
assert release.wait(2)
else:
second_entered.set()
assert context.default_socket is local
assert socketutil.send_msg is not original_send
except BaseException as exc:
errors.append(exc)
first = threading.Thread(target=run, args=(True,))
second = threading.Thread(target=run, args=(False,))
try:
first.start()
assert entered.wait(2)
second.start()
assert second_attempt.wait(2)
assert not second_entered.wait(0.1)
release.set()
first.join(3)
second.join(3)
assert not first.is_alive() and not second.is_alive()
assert not errors
assert second_entered.is_set()
assert context.default_socket is previous
assert previous.fileno() != -1
assert all(sock.fileno() == -1 for sock in owned_sockets)
assert socketutil.send_msg is original_send
assert socketutil.SocketUtil.connect is original_connect
finally:
release.set()
first.join(3)
if second.ident is not None:
second.join(3)
peer.close()
previous.close()
def test_guard_contention_is_bounded_and_does_not_change_active_session() -> None:
from backtest.loaders.baostock_loader import _BAOSTOCK_LOCK
original_send = bs.util.socketutil.send_msg
with _BAOSTOCK_LOCK:
started = time.monotonic()
with _baostock_socket_guard(0.05) as acquired:
assert acquired is False
assert time.monotonic() - started < 1
assert bs.util.socketutil.send_msg is original_send
@pytest.mark.parametrize("mode", ["silent", "closed"])
def test_real_sdk_login_failure_reaches_market_data_fallback(monkeypatch, mode):
import pandas as pd
from backtest.loaders.baostock_loader import DataLoader
from src.market_data import fetch_market_data
finished = threading.Event()
def handler(conn):
conn.recv(4096)
if mode == "silent":
finished.wait(2)
port = _serve_once(handler)
monkeypatch.setattr(cons, "BAOSTOCK_SERVER_IP", "127.0.0.1")
monkeypatch.setattr(cons, "BAOSTOCK_SERVER_PORT", port)
monkeypatch.setenv(_READ_TIMEOUT_ENV, "0.1")
called = []
class FallbackLoader:
def fetch(self, codes, *args, **kwargs):
called.extend(codes)
return {
codes[0]: pd.DataFrame(
{"open": [1.0], "high": [1.0], "low": [1.0], "close": [1.0], "volume": [1.0]},
index=pd.to_datetime(["2024-01-02"]),
)
}
try:
result = fetch_market_data(
codes=["601398.SH"],
start_date="2024-01-01",
end_date="2024-01-03",
source="baostock",
max_fallback_attempts=2,
loader_resolver=lambda source: DataLoader if source == "baostock" else FallbackLoader,
fallback_chain_provider=lambda source: ["baostock", "fallback"],
)
assert called == ["601398.SH"]
assert result["601398.SH"]
assert context.default_socket is None
finally:
finished.set()
def test_blocked_send_is_bounded_and_discards_connection():
finished = threading.Event()
port = _serve_once(lambda conn: finished.wait(2))
sock = _connect(port)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 1024)
context.default_socket = sock
try:
started = time.monotonic()
assert _bounded_send_msg("x" * 8_000_000, timeout=0.1) is None
assert time.monotonic() - started < 1
assert context.default_socket is None
assert sock.fileno() == -1
finally:
finished.set()
def test_guard_restores_after_failed_session():
original_send = bs.util.socketutil.send_msg
original_connect = bs.util.socketutil.SocketUtil.connect
owned, peer = socket.socketpair()
try:
with pytest.raises(RuntimeError, match="failed session"):
with _baostock_socket_guard(0.1) as acquired:
assert acquired
context.default_socket = owned
raise RuntimeError("failed session")
assert owned.fileno() == -1
assert context.default_socket is None
assert bs.util.socketutil.send_msg is original_send
assert bs.util.socketutil.SocketUtil.connect is original_connect
with _baostock_socket_guard(0.1) as acquired:
assert acquired
finally:
owned.close()
peer.close()
+63 -10
View File
@@ -18,17 +18,62 @@ def test_capability_matrix_block_in_readme_is_current() -> None:
)
def test_capability_matrix_covers_every_connector() -> None:
def _rows() -> dict[str, list[str]]:
return {
cells[0].strip("`"): cells
for line in render().splitlines()
if line.startswith("| `")
for cells in [[cell.strip() for cell in line.strip("|").split("|")]]
}
def test_each_profile_keeps_its_own_permissions_and_transport() -> None:
rows = _rows()
assert set(rows) == {p.id for p in BUILTIN_PROFILES}
assert len(rows) == len(BUILTIN_PROFILES)
for profile in BUILTIN_PROFILES:
row = rows[profile.id]
assert row[1:4] == [profile.connector, profile.environment, profile.transport]
assert row[4] == ("read-only" if profile.readonly else "write-enabled")
rendered_caps = set()
for cell in (row[5], row[7]):
if cell != "none declared":
rendered_caps.update(item.strip("`") for item in cell.split(", "))
assert rendered_caps == set(profile.capabilities)
assert row[6] == (profile.transport if "quotes.read" in profile.capabilities else "none declared")
def test_paper_order_capability_never_becomes_a_live_permission() -> None:
rows = _rows()
for broker in ("upbit", "dhan", "shoonya", "zerodha", "longbridge"):
profiles = [p for p in BUILTIN_PROFILES if p.connector == broker]
assert any(p.environment == "paper" and "orders.place" in p.capabilities for p in profiles)
for profile in profiles:
if profile.environment == "live":
assert rows[profile.id][8] == "disabled (read-only)"
assert "orders.place" not in rows[profile.id][7]
assert rows["alpaca-live-trade"][8] == "mandate required"
assert rows["robinhood-live-mcp"][8] == "mandate required"
assert rows["robinhood-live-mcp-readonly"][6] == "none declared"
assert "positions.edit" in rows["etoro-live-trade"][7]
def test_only_builtin_declarations_are_rendered(monkeypatch) -> None:
from dataclasses import replace
from src.trading import capability_matrix
profile = replace(
BUILTIN_PROFILES[0],
id="test-profile",
capabilities=("quotes.read", "future.capability"),
config={"credential": "DO-NOT-PUBLISH"},
notes="DO-NOT-PUBLISH",
)
monkeypatch.setattr(capability_matrix, "BUILTIN_PROFILES", (profile,))
rendered = render()
rows = [
line
for line in rendered.splitlines()
if line.startswith("| ") and "---" not in line and "Connector" not in line
]
connectors = {p.connector for p in BUILTIN_PROFILES}
assert len(rows) == len(connectors)
for connector in connectors:
assert f"| {connector} |" in rendered
assert "DO-NOT-PUBLISH" not in rendered
assert "future.capability" in rendered
assert "not successful runtime" in rendered
def test_sync_readme_replaces_only_the_marked_block() -> None:
@@ -39,3 +84,11 @@ def test_sync_readme_replaces_only_the_marked_block() -> None:
# without regeneration would
without = text.replace(render(), "stale\n")
assert sync_readme(without) == text # regenerates back to current
def test_sync_readme_rejects_missing_or_duplicate_markers() -> None:
import pytest
for text in ("no block", render_block() + render_block()):
with pytest.raises(ValueError):
sync_readme(text)
+134
View File
@@ -3,8 +3,11 @@
from __future__ import annotations
import json
import sys
import threading
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import ModuleType, SimpleNamespace
import pytest
@@ -140,6 +143,137 @@ def test_sdk_client_options_keep_stored_cli_credentials_enabled(
assert copilot_auth._client_options() == {"github_token": "gho_explicit"}
@pytest.mark.parametrize(("initial", "refreshed"), [("", "gho_new_login"), ("gho_logged_out", "")])
def test_credential_cache_refreshes_empty_and_removed_credentials(
no_ambient_credentials, monkeypatch, initial, refreshed
) -> None:
now = [0.0]
tokens = iter((initial, refreshed))
monkeypatch.setattr(caps_mod, "monotonic", lambda: now[0])
monkeypatch.setattr(copilot_auth, "gh_cli_token", lambda: next(tokens))
assert get_llm_credentials("copilot", "test-model")["api_key"] == initial
now[0] = 60.0
assert get_llm_credentials("copilot", "test-model")["api_key"] == refreshed
def test_concurrent_expired_credential_reads_share_one_refresh(no_ambient_credentials, monkeypatch) -> None:
caps_mod._gh_cli_token_cache = (0.0, "gho_expired")
monkeypatch.setattr(caps_mod, "monotonic", lambda: 60.0)
callers = threading.Barrier(8)
refresh_started = threading.Event()
release_refresh = threading.Event()
calls = []
def resolve():
calls.append(None)
refresh_started.set()
assert release_refresh.wait(5)
return "gho_refreshed", "gh auth token"
def read():
callers.wait(timeout=5)
return get_llm_credentials("copilot", "test-model")["api_key"]
monkeypatch.setattr(copilot_auth, "resolve_copilot_token", resolve)
with ThreadPoolExecutor(max_workers=8) as pool:
futures = [pool.submit(read) for _ in range(8)]
try:
assert refresh_started.wait(5)
finally:
release_refresh.set()
assert [future.result(timeout=5) for future in futures] == ["gho_refreshed"] * 8
assert len(calls) == 1
@pytest.mark.parametrize("failure", ["timeout", "nonzero", "unsupported"])
def test_failed_cli_refresh_does_not_reuse_expired_token(monkeypatch, failure):
caps_mod._gh_cli_token_cache = (0.0, "gho_expired")
monkeypatch.setattr(caps_mod, "monotonic", lambda: 60.0)
monkeypatch.setattr(copilot_auth.shutil, "which", lambda _name: "/fake/gh")
def run(*_args, **_kwargs):
if failure == "timeout":
raise copilot_auth.subprocess.TimeoutExpired("gh", 10)
return SimpleNamespace(
returncode=1 if failure == "nonzero" else 0,
stdout="gho_rejected" if failure == "nonzero" else "ghp_unsupported",
)
monkeypatch.setattr(copilot_auth.subprocess, "run", run)
monkeypatch.setattr(
copilot_auth,
"resolve_copilot_token",
lambda: (copilot_auth.gh_cli_token(), "gh auth token"),
)
assert caps_mod._gh_cli_token() == ""
assert caps_mod._gh_cli_token_cache == (60.0, "")
def test_same_sdk_wrapper_resolves_credentials_for_each_request(no_ambient_credentials, monkeypatch) -> None:
"""Exercise the real wrapper/client boundary without a Copilot account."""
sdk = ModuleType("copilot")
events = ModuleType("copilot.session_events")
sdk_tools = ModuleType("copilot.tools")
for name in (
"AssistantMessageData",
"AssistantMessageDeltaData",
"AssistantReasoningDeltaData",
"ExternalToolRequestedData",
"SessionErrorData",
"SessionIdleData",
):
setattr(events, name, type(name, (SimpleNamespace,), {}))
sdk_tools.Tool = SimpleNamespace
sdk.ToolSet = SimpleNamespace
seen_options = []
class Session:
def __init__(self, on_event):
self.on_event = on_event
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def send(self, _prompt):
self.on_event(
SimpleNamespace(data=events.AssistantMessageData(content="ok", model="test-model", tool_requests=[]))
)
self.on_event(SimpleNamespace(data=events.SessionIdleData()))
class Client:
def __init__(self, **options):
seen_options.append(options)
async def __aenter__(self):
return self
async def __aexit__(self, *_args):
return None
async def create_session(self, **options):
return Session(options["on_event"])
sdk.CopilotClient = Client
for name, module in (
("copilot", sdk),
("copilot.session_events", events),
("copilot.tools", sdk_tools),
):
monkeypatch.setitem(sys.modules, name, module)
tokens = iter(("gho_first", "gho_rotated", ""))
monkeypatch.setattr(copilot_auth, "gh_cli_token", lambda: next(tokens))
# A stale diagnostic cache must never leak into the SDK request path.
caps_mod._gh_cli_token_cache = (0.0, "gho_stale_diagnostic")
llm = copilot_auth.CopilotSDKLLM(model="test-model", timeout=2)
for _ in range(3):
assert llm.invoke([{"role": "user", "content": "hello"}]).content == "ok"
assert seen_options == [{"github_token": "gho_first"}, {"github_token": "gho_rotated"}, {}]
def test_credentials_do_not_require_openai_base_url(
no_ambient_credentials, monkeypatch
) -> None:
+177 -107
View File
@@ -1,129 +1,199 @@
"""Weekly live-source health canary (#1623).
"""Offline contracts for the separately scheduled public-source canary."""
For every registered loader that needs no credentials, fetch one known-liquid
symbol over a recent window and assert the normalized frame comes back
non-empty, in the expected columnar shape, with a last bar no older than two
weeks. The point is that a dead or drifted third-party endpoint fails here
before a user files it.
Off the default CI lane: this module only runs when VIBE_TRADING_LOADER_HEALTH=1
(the weekly loader-health workflow sets it). A loader that cannot be reached
from the runner at all (geo-blocked or network-filtered) is reported as
unreachable rather than failed when the fetch raises a network error; an empty,
malformed, or stale answer is a hard failure, because that is what drift
actually looks like. Note loaders swallow request failures into an empty
result, so an empty frame can mean runner connectivity or endpoint drift —
either way it deserves eyes, and the retry plus weekly cadence keep transient
flaps from crying wolf.
"""
from __future__ import annotations
import os
import socket
from datetime import date, timedelta
import json
import subprocess
import pandas as pd
import pytest
import requests
from backtest.loaders.registry import LOADER_REGISTRY, _ensure_registered
from backtest import loader_health as health
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
os.environ.get("VIBE_TRADING_LOADER_HEALTH") != "1",
reason="weekly loader-health lane only (VIBE_TRADING_LOADER_HEALTH=1)",
),
]
TODAY = date(2026, 9, 28)
# One known-liquid symbol per no-auth loader, in that loader's own format.
CANARY_SYMBOLS: dict[str, str] = {
"akshare": "601398",
"baostock": "sh.601398",
"binance": "BTC/USDT",
"ccxt": "BTC/USDT",
"eastmoney": "601398.SH",
"mootdx": "601398",
"nobitex": "BTC-IRT",
"okx": "BTC-USDT",
"pykrx": "005930.KS",
"sina": "AAPL.US",
"stooq": "AAPL.US",
"tencent": "601398.SH",
"wallex": "BTC-TMN",
"yahoo": "AAPL",
"yfinance": "AAPL",
}
_FRESHNESS_DAYS = 14 # equities close on weekends and holidays
_WINDOW_DAYS = 21 # fetch window comfortably covers the freshness window
def frame(age=0):
return pd.DataFrame(
{"open": [10.0], "high": [12.0], "low": [9.0], "close": [11.0], "volume": [100.0]},
index=pd.DatetimeIndex([TODAY - timedelta(days=age)]),
)
# Exceptions that mean "the runner cannot reach this source", not "the source
# is broken". Those are reported, not failed.
_UNREACHABLE = (
ConnectionError,
TimeoutError,
socket.timeout,
requests.exceptions.ConnectionError,
requests.exceptions.ConnectTimeout,
requests.exceptions.ReadTimeout,
def test_catalog_covers_every_public_network_loader():
assert health.coverage_errors() == []
def test_new_loader_cannot_silently_disappear(monkeypatch):
from backtest.loaders.registry import LOADER_REGISTRY
monkeypatch.setitem(LOADER_REGISTRY, "new_public_source", type("Loader", (), {"requires_auth": False}))
assert "unmapped:new_public_source" in health.coverage_errors()
@pytest.mark.parametrize("age,status", [(0, "healthy"), (14, "healthy"), (15, "stale"), (-1, "invalid")])
def test_freshness_bounds(age, status):
assert health.check_frame(frame(age), TODAY)["status"] == status
@pytest.mark.parametrize(
"change,reason",
[
(lambda f: f.iloc[:0], "empty_frame"),
(lambda f: f.drop(columns="close"), "schema"),
(lambda f: f.assign(close="11"), "numeric_columns"),
(lambda f: f.assign(close=float("nan")), "nonfinite_values"),
(lambda f: f.assign(high=5), "ohlc_order"),
(lambda f: f.assign(volume=-1), "invalid_prices_or_volume"),
(lambda f: f.reset_index(drop=True), "datetime_index"),
(lambda f: pd.concat([f, f]), "index_order"),
],
)
def test_normalized_contract(change, reason):
assert health.check_frame(change(frame()), TODAY)["reason"] == reason
def _canary_loaders() -> list[tuple[str, type, str]]:
_ensure_registered()
out = []
for name, cls in sorted(LOADER_REGISTRY.items()):
if name not in CANARY_SYMBOLS or getattr(cls, "requires_auth", True):
continue
out.append((name, cls, CANARY_SYMBOLS[name]))
return out
def test_no_keys_operator_home_or_cache_in_child(monkeypatch, tmp_path):
monkeypatch.setenv("GITHUB_TOKEN", "private-test-value")
monkeypatch.setenv("OPENAI_API_KEY", "private-test-value")
monkeypatch.setenv("VIBE_TRADING_DATA_CACHE", "1")
monkeypatch.setenv("PYTHONPATH", "/operator/overrides")
env = health.child_environment(str(tmp_path))
assert "GITHUB_TOKEN" not in env and "OPENAI_API_KEY" not in env
assert env["HOME"] == env["VIBE_TRADING_HOME"] == str(tmp_path)
assert env["VIBE_TRADING_DATA_CACHE"] == "0"
assert env["PYTHONPATH"] != "/operator/overrides"
def _collect() -> list[str]:
return [name for name, _, _ in _canary_loaders()]
def test_unavailable_endpoint_is_not_skipped(monkeypatch):
from backtest.loaders.registry import LOADER_REGISTRY
monkeypatch.setitem(
LOADER_REGISTRY,
"tencent",
type(
"Loader",
(),
{
"requires_auth": False,
"is_available": lambda self: False,
},
),
)
assert health.probe("tencent", TODAY) == {
"status": "unavailable",
"reason": "availability_probe_failed",
"attempts": 2,
}
@pytest.mark.parametrize("loader_name", _collect(), ids=_collect())
def test_loader_canary(loader_name: str) -> None:
_, cls, symbol = next((n, c, s) for n, c, s in _canary_loaders() if n == loader_name)
loader = cls()
if not loader.is_available():
pytest.skip(f"{loader_name} package not installed in this environment")
def test_network_error_is_sanitized_and_retried(monkeypatch):
from backtest.loaders.registry import LOADER_REGISTRY
end = date.today()
start = end - timedelta(days=_WINDOW_DAYS)
calls = []
last_exc: Exception | None = None
result = None
for attempt in (1, 2): # one retry rides out a flaky first connection
try:
result = loader.fetch(
[symbol],
start.strftime("%Y-%m-%d"),
end.strftime("%Y-%m-%d"),
interval="1D",
)
last_exc = None
break
except _UNREACHABLE as exc:
last_exc = exc
except Exception as exc: # noqa: BLE001 - reported by class below
last_exc = exc
break
def fetch(self, *args, **kwargs):
calls.append(args)
raise requests.exceptions.ConnectionError("https://secret:token@internal.example")
if last_exc is not None:
if isinstance(last_exc, _UNREACHABLE):
pytest.skip(f"{loader_name} unreachable from this runner: {last_exc}")
pytest.fail(f"{loader_name} raised {type(last_exc).__name__}: {last_exc}")
monkeypatch.setitem(
LOADER_REGISTRY,
"tencent",
type(
"Loader",
(),
{
"requires_auth": False,
"is_available": lambda self: True,
"fetch": fetch,
},
),
)
result = health.probe("tencent", TODAY)
assert result["status"] == "unreachable" and len(calls) == 2
assert "secret" not in json.dumps(result)
assert result is not None and symbol in result, f"{loader_name} returned no frame for {symbol}: {result!r}"
frame = result[symbol]
assert not frame.empty, f"{loader_name} returned an empty frame for {symbol}"
for col in ("open", "high", "low", "close", "volume"):
assert col in frame.columns, f"{loader_name} frame missing {col}"
last_bar = frame.index[-1]
last_date = last_bar.date() if hasattr(last_bar, "date") else last_bar
age = (date.today() - last_date).days
assert age <= _FRESHNESS_DAYS, f"{loader_name} last bar for {symbol} is {last_date} ({age}d old)"
def test_child_deadline_is_failure(monkeypatch):
def timeout(*args, **kwargs):
assert kwargs["timeout"] == 0.1
raise subprocess.TimeoutExpired(args[0], 0.1)
monkeypatch.setattr(health.subprocess, "run", timeout)
assert health.run_source("tencent", TODAY, 0.1)["status"] == "timeout"
def test_real_child_is_killed_at_deadline():
assert health.run_source("tencent", TODAY, 0.001)["status"] == "timeout"
@pytest.mark.parametrize(
"status,coverage,exit_code",
[
("healthy", [], 0),
("unavailable", [], 1),
("missing_dependency", [], 1),
("healthy", ["unmapped:new"], 1),
],
)
def test_lane_exit_and_report(monkeypatch, tmp_path, status, coverage, exit_code):
output = tmp_path / "report.json"
monkeypatch.setattr(health.sys, "argv", ["health", "--output", str(output)])
monkeypatch.setattr(health, "CANARY_SYMBOLS", {"test": "TEST"})
monkeypatch.setattr(health, "coverage_errors", lambda: coverage)
monkeypatch.setattr(health, "run_source", lambda *args: {"source": "test", "status": status})
assert health.main() == exit_code
report = json.loads(output.read_text())
assert report["coverage_errors"] == coverage
assert report["sources"] == [{"source": "test", "status": status}]
@pytest.mark.parametrize(
"payload,status",
[
('{"status":"healthy","rows":2}', "healthy"),
("not json", "error"),
("[]", "error"),
('{"status":"skip"}', "error"),
(None, "error"),
],
)
def test_child_report_handling(monkeypatch, payload, status):
from pathlib import Path
def child(command, **kwargs):
if payload is not None:
Path(command[-1]).write_text(payload)
return subprocess.CompletedProcess(command, 0)
monkeypatch.setattr(health.subprocess, "run", child)
assert health.run_source("tencent", TODAY, 1)["status"] == status
def test_second_attempt_recovers_without_fallback(monkeypatch):
from backtest.loaders.registry import LOADER_REGISTRY
calls = []
def fetch(self, codes, start, end, *, interval):
calls.append(codes)
if len(calls) == 1:
return {}
return {"601398.SH": frame()}
monkeypatch.setitem(
LOADER_REGISTRY,
"tencent",
type(
"Loader",
(),
{
"requires_auth": False,
"is_available": lambda self: True,
"fetch": fetch,
},
),
)
result = health.probe("tencent", TODAY)
assert result["status"] == "healthy" and result["attempts"] == 2
assert calls == [["601398.SH"], ["601398.SH"]]
+17
View File
@@ -0,0 +1,17 @@
# Standalone public-loader canary environment (Python 3.11).
# Install into its own venv; import repository sources with PYTHONPATH=agent.
# Do not install the full application: mootdx 0.11.7 requires httpx <0.26,
# while the application's LLM dependencies require newer httpx.
pandas>=2.2,<3
numpy>=1.26,<3
requests>=2.31,<3
pydantic>=2,<3
PyYAML>=6,<7
certifi
httpx>=0.25,<0.26
yfinance>=0.2.54,<2
akshare>=1.17,<2
ccxt>=4,<5
baostock==0.9.3
mootdx==0.11.7
pykrx>=1.0,<2