139 lines
4.1 KiB
Python
139 lines
4.1 KiB
Python
import asyncio
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from services.executor import AnalysisExecutorError, LegacySubprocessAnalysisExecutor
|
|
from services.request_context import build_request_context
|
|
|
|
|
|
class _FakeStdout:
|
|
def __init__(self, lines, *, stall: bool = False):
|
|
self._lines = list(lines)
|
|
self._stall = stall
|
|
|
|
async def readline(self):
|
|
if self._stall:
|
|
await asyncio.sleep(3600)
|
|
if self._lines:
|
|
return self._lines.pop(0)
|
|
return b""
|
|
|
|
|
|
class _FakeStderr:
|
|
def __init__(self, payload: bytes = b""):
|
|
self._payload = payload
|
|
|
|
async def read(self):
|
|
return self._payload
|
|
|
|
|
|
class _FakeProcess:
|
|
def __init__(self, stdout, *, stderr: bytes = b"", returncode=None):
|
|
self.stdout = stdout
|
|
self.stderr = _FakeStderr(stderr)
|
|
self.returncode = returncode
|
|
self.kill_called = False
|
|
self.wait_called = False
|
|
|
|
async def wait(self):
|
|
self.wait_called = True
|
|
if self.returncode is None:
|
|
self.returncode = -9 if self.kill_called else 0
|
|
return self.returncode
|
|
|
|
def kill(self):
|
|
self.kill_called = True
|
|
self.returncode = -9
|
|
|
|
|
|
def test_executor_raises_when_required_markers_missing(monkeypatch):
|
|
process = _FakeProcess(
|
|
_FakeStdout(
|
|
[
|
|
b"STAGE:analysts\n",
|
|
b"STAGE:portfolio\n",
|
|
b"SIGNAL_DETAIL:{\"quant_signal\":\"BUY\",\"llm_signal\":\"BUY\",\"confidence\":0.8}\n",
|
|
],
|
|
),
|
|
returncode=0,
|
|
)
|
|
|
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
|
return process
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
|
|
|
executor = LegacySubprocessAnalysisExecutor(
|
|
analysis_python=Path("/usr/bin/python3"),
|
|
repo_root=Path("."),
|
|
api_key_resolver=lambda: "env-key",
|
|
)
|
|
|
|
async def scenario():
|
|
with pytest.raises(AnalysisExecutorError, match="required markers: ANALYSIS_COMPLETE"):
|
|
await executor.execute(
|
|
task_id="task-1",
|
|
ticker="AAPL",
|
|
date="2026-04-13",
|
|
request_context=build_request_context(api_key="ctx-key"),
|
|
)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
|
|
def test_executor_kills_subprocess_on_timeout(monkeypatch):
|
|
process = _FakeProcess(_FakeStdout([], stall=True))
|
|
|
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
|
return process
|
|
|
|
monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
|
|
|
|
executor = LegacySubprocessAnalysisExecutor(
|
|
analysis_python=Path("/usr/bin/python3"),
|
|
repo_root=Path("."),
|
|
api_key_resolver=lambda: "env-key",
|
|
stdout_timeout_secs=0.01,
|
|
)
|
|
|
|
async def scenario():
|
|
with pytest.raises(AnalysisExecutorError, match="timed out"):
|
|
await executor.execute(
|
|
task_id="task-2",
|
|
ticker="AAPL",
|
|
date="2026-04-13",
|
|
request_context=build_request_context(api_key="ctx-key"),
|
|
)
|
|
|
|
asyncio.run(scenario())
|
|
|
|
assert process.kill_called is True
|
|
assert process.wait_called is True
|
|
|
|
|
|
def test_executor_marks_degraded_success_when_result_meta_reports_data_quality():
|
|
output = LegacySubprocessAnalysisExecutor._parse_output(
|
|
stdout_lines=[
|
|
'SIGNAL_DETAIL:{"quant_signal":"HOLD","llm_signal":"BUY","confidence":0.6}',
|
|
'RESULT_META:{"degrade_reason_codes":["non_trading_day"],"data_quality":{"state":"non_trading_day","requested_date":"2026-04-12"}}',
|
|
"ANALYSIS_COMPLETE:OVERWEIGHT",
|
|
],
|
|
ticker="AAPL",
|
|
date="2026-04-12",
|
|
contract_version="v1alpha1",
|
|
executor_type="legacy_subprocess",
|
|
)
|
|
|
|
contract = output.to_result_contract(
|
|
task_id="task-3",
|
|
ticker="AAPL",
|
|
date="2026-04-12",
|
|
created_at="2026-04-12T10:00:00",
|
|
elapsed_seconds=3,
|
|
)
|
|
|
|
assert contract["status"] == "degraded_success"
|
|
assert contract["data_quality"]["state"] == "non_trading_day"
|
|
assert contract["degradation"]["reason_codes"] == ["non_trading_day"]
|