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:
1 parent
3562ac7696
commit
dec2c0e6b8
18 files changed
+1151
-202
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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))。
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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))。
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
@@ -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"]]
|
||||
@@ -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
|
||||
Reference in new issue
Block a user