mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-04 00:22:43 +02:00
fix(router): report a vendor chain with an unavailable vendor as unavailable, not as no data
- a vendor that throttled or failed the request never said whether it has the symbol, so another vendor's "no data" no longer speaks for the chain
This commit is contained in:
@@ -94,6 +94,26 @@ class RouterHandlesBaseTypesTests(unittest.TestCase):
|
||||
), self.assertRaises(AlphaVantageNotConfiguredError):
|
||||
router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
|
||||
def test_an_unavailable_vendor_keeps_absence_from_being_claimed(self):
|
||||
# The throttled vendor's coverage was never learned, so "no usable data
|
||||
# from any configured vendor" would be a claim nobody checked.
|
||||
def throttled(*a, **k):
|
||||
raise VendorUnavailableError("Yahoo HTTP 429")
|
||||
|
||||
def timed_out(*a, **k):
|
||||
raise TimeoutError("read timed out") # a vendor that does not type its failures
|
||||
|
||||
def not_covered(*a, **k):
|
||||
raise NoMarketDataError("AAPL", "AAPL", "not covered")
|
||||
|
||||
for chain in ({"yfinance": throttled, "alpha_vantage": not_covered},
|
||||
{"yfinance": not_covered, "alpha_vantage": throttled},
|
||||
{"alpha_vantage": timed_out, "yfinance": not_covered}):
|
||||
set_config({"data_vendors": {"core_stock_apis": ",".join(chain)}})
|
||||
with mock.patch.dict(router.VENDOR_METHODS, {"get_stock_data": chain}, clear=False):
|
||||
out = router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
self.assertTrue(out.startswith("DATA_UNAVAILABLE"), out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -72,13 +72,14 @@ class VendorRoutingTests(unittest.TestCase):
|
||||
self.assertEqual(result, "AV_DATA")
|
||||
|
||||
def test_primary_error_is_logged_not_masked(self):
|
||||
# #989: primary errors + fallback no-data -> NO_DATA, but the failure
|
||||
# must be visible in logs (broken primary not hidden).
|
||||
# #989: a broken primary is not hidden behind a fallback's verdict. It
|
||||
# never said whether it has the symbol, so the answer is unavailable,
|
||||
# and the failure is in the logs.
|
||||
set_config({"data_vendors": {"core_stock_apis": "yfinance,alpha_vantage"}})
|
||||
with self._route({"yfinance": _raises(ValueError("boom")), "alpha_vantage": _no_data}), \
|
||||
self.assertLogs("tradingagents.dataflows.router", level="WARNING") as cm:
|
||||
result = router.route_to_vendor("get_stock_data", "AAPL", "2026-01-01", "2026-01-10")
|
||||
self.assertIn("NO_DATA_AVAILABLE", result)
|
||||
self.assertTrue(result.startswith("DATA_UNAVAILABLE"), result)
|
||||
joined = "\n".join(cm.output)
|
||||
self.assertIn("boom", joined) # the real error surfaced in logs
|
||||
self.assertIn("yfinance", joined)
|
||||
|
||||
@@ -173,7 +173,7 @@ def get_vendor(category: str, method: str = None) -> str:
|
||||
return config.get("data_vendors", {}).get(category, "default")
|
||||
|
||||
|
||||
def vendor_unavailable(method: str, error: VendorUnavailableError) -> str:
|
||||
def vendor_unavailable(method: str, error: Exception) -> str:
|
||||
"""What a call returns when every vendor was throttled or unreachable."""
|
||||
return (
|
||||
f"DATA_UNAVAILABLE: no configured vendor could serve {method} right now "
|
||||
@@ -211,6 +211,7 @@ def route_to_vendor(method: str, *args, **kwargs):
|
||||
|
||||
last_no_data: NoMarketDataError | None = None
|
||||
last_unavailable: VendorUnavailableError | None = None
|
||||
failed: Exception | None = None # a vendor that raised something untyped
|
||||
first_error: Exception | None = None
|
||||
for vendor in vendor_chain:
|
||||
vendor_impl = VENDOR_METHODS[method][vendor]
|
||||
@@ -239,9 +240,19 @@ def route_to_vendor(method: str, *args, **kwargs):
|
||||
logger.warning("Vendor %r failed for %s: %s", vendor, method, e)
|
||||
if first_error is None:
|
||||
first_error = e
|
||||
failed = e
|
||||
continue
|
||||
|
||||
# If any vendor reported "no data", the symbol is genuinely unavailable.
|
||||
# A vendor that throttled or failed the request never said whether it has
|
||||
# the symbol, so no other vendor's "no data" can speak for the whole chain:
|
||||
# report the vendors as the problem, not the instrument. It must not end
|
||||
# the run either.
|
||||
if last_unavailable is not None:
|
||||
return vendor_unavailable(method, last_unavailable)
|
||||
if failed is not None and last_no_data is not None:
|
||||
return vendor_unavailable(method, failed)
|
||||
|
||||
# Every vendor that answered reported "no data": the symbol is genuinely unavailable.
|
||||
# Return one explicit, instructive sentinel rather than a vendor-specific
|
||||
# empty string, so the agent reports "unavailable" instead of inventing a
|
||||
# value. This takes precedence over incidental fallback errors.
|
||||
@@ -271,11 +282,6 @@ def route_to_vendor(method: str, *args, **kwargs):
|
||||
# first real error (e.g. the primary vendor's network failure). Optional
|
||||
# enrichment categories degrade to a sentinel instead, so flavour data can't
|
||||
# abort the run.
|
||||
# Every vendor was throttled or unreachable: that is a fact about the
|
||||
# vendors, not about the instrument, and it must not end the run.
|
||||
if last_unavailable is not None:
|
||||
return vendor_unavailable(method, last_unavailable)
|
||||
|
||||
if first_error is not None:
|
||||
if category in OPTIONAL_CATEGORIES:
|
||||
logger.warning("Optional %s unavailable for %s: %s", category, method, first_error)
|
||||
|
||||
Reference in New Issue
Block a user