Skip to content

Commit fc68f89

Browse files
committed
Enhance error handling and exception management in mod teardown and main execution flow
- Updated the `tear_down` method in `ModHandler` to collect exceptions and raise an `ExceptionGroup` if multiple errors occur during module teardown. - Modified the `run` function in `main.py` to re-raise exceptions after handling, ensuring that unexpected errors are not silently caught. - Improved type handling for pandas Series in `mod.py` to explicitly set the data type, enhancing data integrity. - Introduced an `integration_test` function in the testing module to facilitate integration testing with result validation.
1 parent c2ff381 commit fc68f89

6 files changed

Lines changed: 193 additions & 7 deletions

File tree

rqalpha/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ def run(config, source_code=None, user_funcs=None):
236236
persist_helper.persist()
237237
code = _exception_handler(e)
238238
mod_handler.tear_down(code, e)
239+
raise e
239240
except Exception as e:
240241
if init_succeed and persist_helper and env.config.base.persist_mode == const.PERSIST_MODE.ON_CRASH:
241242
persist_helper.persist()
@@ -245,6 +246,7 @@ def run(config, source_code=None, user_funcs=None):
245246

246247
code = _exception_handler(user_exc)
247248
mod_handler.tear_down(code, user_exc)
249+
raise user_exc
248250
else:
249251
if persist_helper and env.config.base.persist_mode == const.PERSIST_MODE.ON_NORMAL_EXIT:
250252
persist_helper.persist()

rqalpha/mod/__init__.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
# 在此前提下,对本软件的使用同样需要遵守 Apache 2.0 许可,Apache 2.0 许可与本许可冲突之处,以本许可为准。
1616
# 详细的授权流程,请联系 public@ricequant.com 获取。
1717

18+
import sys
1819
import copy
1920
import typing
2021
from collections import OrderedDict
@@ -23,7 +24,8 @@
2324
from rqalpha.utils.package_helper import import_mod
2425
from rqalpha.utils.logger import system_log
2526
from rqalpha.utils.i18n import gettext as _
26-
from rqalpha.utils import RqAttrDict
27+
from rqalpha.utils import RqAttrDict, create_custom_exception
28+
from rqalpha.utils.exception import ExceptionGroup
2729

2830

2931
class ModHandler(object):
@@ -74,16 +76,22 @@ def start_up(self):
7476

7577
def tear_down(self, *args):
7678
result = {}
79+
exceptions = []
7780
for mod_name, __ in reversed(self._mod_list):
7881
try:
7982
system_log.debug(_(u"mod tear_down [START] {}").format(mod_name))
8083
ret = self._mod_dict[mod_name].tear_down(*args)
8184
system_log.debug(_(u"mod tear_down [END] {}").format(mod_name))
8285
except Exception as e:
86+
exc_type, exc_val, exc_tb = sys.exc_info()
87+
exceptions.append(create_custom_exception(exc_type, exc_val, exc_tb, self._env.config.base.strategy_file))
8388
system_log.exception("tear down fail for {}", mod_name)
8489
continue
85-
if ret is not None:
86-
result[mod_name] = ret
90+
else:
91+
if ret is not None:
92+
result[mod_name] = ret
93+
if exceptions:
94+
raise ExceptionGroup("Mod tear down failed", exceptions)
8795
return result
8896

8997

rqalpha/mod/rqalpha_mod_sys_analyser/mod.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,8 +470,8 @@ def tear_down(self, code, exception=None):
470470
result_dict["summary"]["excess_max_drawdown_duration_end_date"] = str(max_ddd.end_date)
471471
result_dict["summary"]["excess_max_drawdown_duration_days"] = (max_ddd.end_date - max_ddd.start_date).days
472472
else:
473-
weekly_b_returns = pandas.Series(index=weekly_returns.index)
474-
monthly_b_returns = pandas.Series(index=monthly_returns.index)
473+
weekly_b_returns = pandas.Series(index=weekly_returns.index, dtype=float)
474+
monthly_b_returns = pandas.Series(index=monthly_returns.index, dtype=float)
475475

476476
# 周度风险指标
477477
weekly_risk = Risk(weekly_returns, weekly_b_returns, risk_free_rate, WEEKLY)

rqalpha/utils/exception.py

Lines changed: 127 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def stacks_length(self):
4242

4343
def __repr__(self):
4444
if len(self.stacks) == 0:
45-
return self.msg
45+
return self.msg or ""
4646

4747
def _repr(v):
4848
try:
@@ -60,7 +60,9 @@ def _repr(v):
6060
for k, v in local_variables.items():
6161
content.append(' --> %s = %s' % (k, _repr(v)))
6262
content.append('')
63-
content.append("%s: %s" % (self.exc_type.__name__, self.msg))
63+
64+
exc_name = self.exc_type.__name__ if self.exc_type else "Exception"
65+
content.append("%s: %s" % (exc_name, self.msg))
6466

6567
return "\n".join(content)
6668

@@ -135,3 +137,126 @@ class RQApiNotSupportedError(RQUserError):
135137
class RQDatacVersionTooLow(RuntimeError):
136138
pass
137139

140+
141+
# ExceptionGroup implementation for compatibility with older Python versions
142+
# Based on Python 3.11's ExceptionGroup behavior
143+
class BaseExceptionGroup(BaseException):
144+
"""A base class for grouping multiple exceptions."""
145+
146+
def __init__(self, message, exceptions):
147+
if not isinstance(message, str):
148+
raise TypeError(f"ExceptionGroup message must be a string, not {type(message).__name__}")
149+
150+
if not exceptions:
151+
raise ValueError("second argument (exceptions) must be a non-empty sequence")
152+
153+
# Convert to list and validate exceptions
154+
exceptions_list = []
155+
for exc in exceptions:
156+
if isinstance(exc, BaseException):
157+
exceptions_list.append(exc)
158+
elif isinstance(exc, type) and issubclass(exc, BaseException):
159+
# Allow exception classes, instantiate them
160+
exceptions_list.append(exc())
161+
else:
162+
raise ValueError(f"Item {exc!r} of second argument is not an exception")
163+
164+
self.message = message
165+
self.exceptions = tuple(exceptions_list)
166+
super().__init__(message)
167+
168+
def __str__(self):
169+
if len(self.exceptions) == 1:
170+
return f"{self.message} (1 sub-exception)"
171+
return f"{self.message} ({len(self.exceptions)} sub-exceptions)"
172+
173+
def __repr__(self):
174+
return f"{self.__class__.__name__}({self.message!r}, {list(self.exceptions)!r})"
175+
176+
def split(self, condition):
177+
"""Split the exception group based on a condition.
178+
179+
Args:
180+
condition: A callable that takes an exception and returns True/False,
181+
or an exception type/tuple of types.
182+
183+
Returns:
184+
A tuple of (matching_group, non_matching_group).
185+
Either element can be None if no exceptions match that category.
186+
"""
187+
if isinstance(condition, type) or (isinstance(condition, tuple) and
188+
all(isinstance(t, type) for t in condition)):
189+
# Handle exception type(s)
190+
def check_condition(exc):
191+
return isinstance(exc, condition)
192+
elif callable(condition):
193+
def check_condition(exc):
194+
result = condition(exc)
195+
return bool(result)
196+
else:
197+
raise TypeError("condition must be a callable or exception type(s)")
198+
199+
matching = []
200+
non_matching = []
201+
202+
for exc in self.exceptions:
203+
if isinstance(exc, BaseExceptionGroup):
204+
# Recursively split nested groups
205+
match_group, non_match_group = exc.split(condition)
206+
if match_group is not None:
207+
matching.append(match_group)
208+
if non_match_group is not None:
209+
non_matching.append(non_match_group)
210+
else:
211+
if check_condition(exc):
212+
matching.append(exc)
213+
else:
214+
non_matching.append(exc)
215+
216+
matching_group = None
217+
if matching:
218+
matching_group = self.derive(matching)
219+
220+
non_matching_group = None
221+
if non_matching:
222+
non_matching_group = self.derive(non_matching)
223+
224+
return (matching_group, non_matching_group)
225+
226+
def subgroup(self, condition):
227+
"""Return a subgroup containing only exceptions that match the condition."""
228+
matching_group, _ = self.split(condition)
229+
return matching_group
230+
231+
def derive(self, exceptions):
232+
"""Create a new exception group with the same message but different exceptions."""
233+
if not exceptions:
234+
return None
235+
return self.__class__(self.message, exceptions)
236+
237+
238+
class ExceptionGroup(BaseExceptionGroup, Exception):
239+
"""An exception group that inherits from Exception."""
240+
pass
241+
242+
243+
def format_exception_group(exc_group, indent=""):
244+
"""Format an ExceptionGroup for display."""
245+
if not isinstance(exc_group, BaseExceptionGroup):
246+
return str(exc_group)
247+
248+
lines = [f"{indent}{exc_group.__class__.__name__}: {exc_group.message}"]
249+
250+
for i, exc in enumerate(exc_group.exceptions):
251+
is_last = (i == len(exc_group.exceptions) - 1)
252+
prefix = "└─ " if is_last else "├─ "
253+
child_indent = " " if is_last else "│ "
254+
255+
if isinstance(exc, BaseExceptionGroup):
256+
lines.append(f"{indent}{prefix}{format_exception_group(exc, indent + child_indent)}")
257+
else:
258+
exc_str = f"{exc.__class__.__name__}: {exc}"
259+
lines.append(f"{indent}{prefix}{exc_str}")
260+
261+
return "\n".join(lines)
262+

rqalpha/utils/testing/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from unittest import TestCase
22

3+
from .integration import integration_test
34
from .mocking import mock_instrument, mock_bar, mock_tick
45
from .fixtures import (
56
MagicMock,
@@ -29,6 +30,7 @@ def setUp(self):
2930

3031

3132
__all__ = [
33+
"integration_test",
3234
"MagicMock",
3335
"RQAlphaFixture",
3436
"RQAlphaTestCase",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import os
2+
from warnings import warn
3+
4+
import pickle
5+
6+
from pandas import DataFrame
7+
8+
from rqalpha import run_func
9+
10+
11+
def _assert_dafaframe(result: DataFrame, expected_result: DataFrame, exclude_columns: list | None = None):
12+
if exclude_columns:
13+
result = result.drop(exclude_columns, axis=1)
14+
expected_result = expected_result.drop(exclude_columns, axis=1)
15+
assert result.equals(expected_result)
16+
17+
18+
def _assert_result(result: dict, expected_result: dict):
19+
actual= result["sys_analyser"]
20+
expected = expected_result["sys_analyser"]
21+
_assert_dafaframe(actual["trades"], expected["trades"], exclude_columns=["order_id", "exec_id"])
22+
23+
for field in [
24+
"stock_positions",
25+
"future_positions",
26+
"stock_account",
27+
"future_account",
28+
"portfolio",
29+
]:
30+
if field in expected:
31+
_assert_dafaframe(actual[field], expected[field])
32+
33+
for summary_field in expected["summary"]:
34+
assert actual["summary"][summary_field] == expected["summary"][summary_field]
35+
36+
37+
def integration_test(result_file: str, **kwargs):
38+
result = run_func(**kwargs)
39+
if not os.path.exists(result_file):
40+
warn(f"Result file {result_file} not found, creating it")
41+
with open(result_file, "wb") as f:
42+
pickle.dump(result, f)
43+
return
44+
with open(result_file, "rb") as f:
45+
expected_result = pickle.load(f)
46+
_assert_result(result, expected_result)
47+
48+
49+

0 commit comments

Comments
 (0)