test_phase2_signals.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. from __future__ import annotations
  2. import unittest
  3. import pandas as pd
  4. from src.portfolio.allocator import allocate_weights
  5. from src.signals.selector import build_signal_panel
  6. from src.signals.trend import apply_trend_filter
  7. def make_signal_input(trade_date: str = "2020-01-10") -> pd.DataFrame:
  8. return pd.DataFrame(
  9. [
  10. {
  11. "instrument": "sse50",
  12. "trade_date": pd.Timestamp(trade_date),
  13. "close": 110,
  14. "daily_return": 0.01,
  15. "ret_5d": 0.08,
  16. "ret_10d": 0.10,
  17. "ret_20d": 0.12,
  18. "ret_60d": 0.20,
  19. "ma_20": 100,
  20. "ma_60": 95,
  21. "vol_10d": 0.10,
  22. "vol_20d": 0.11,
  23. },
  24. {
  25. "instrument": "hs300",
  26. "trade_date": pd.Timestamp(trade_date),
  27. "close": 108,
  28. "daily_return": 0.01,
  29. "ret_5d": 0.05,
  30. "ret_10d": 0.06,
  31. "ret_20d": 0.08,
  32. "ret_60d": 0.12,
  33. "ma_20": 102,
  34. "ma_60": 101,
  35. "vol_10d": 0.12,
  36. "vol_20d": 0.13,
  37. },
  38. {
  39. "instrument": "chinext50",
  40. "trade_date": pd.Timestamp(trade_date),
  41. "close": 96,
  42. "daily_return": -0.01,
  43. "ret_5d": -0.02,
  44. "ret_10d": -0.01,
  45. "ret_20d": -0.03,
  46. "ret_60d": 0.02,
  47. "ma_20": 98,
  48. "ma_60": 100,
  49. "vol_10d": 0.18,
  50. "vol_20d": 0.20,
  51. },
  52. {
  53. "instrument": "star50",
  54. "trade_date": pd.Timestamp(trade_date),
  55. "close": 102,
  56. "daily_return": 0.00,
  57. "ret_5d": 0.02,
  58. "ret_10d": 0.03,
  59. "ret_20d": 0.01,
  60. "ret_60d": 0.04,
  61. "ma_20": 101,
  62. "ma_60": 103,
  63. "vol_10d": 0.08,
  64. "vol_20d": 0.09,
  65. },
  66. ]
  67. )
  68. class SignalLayerTests(unittest.TestCase):
  69. def test_trend_filter_requires_at_least_two_rules(self) -> None:
  70. frame = apply_trend_filter(make_signal_input())
  71. outcome = frame.set_index("instrument")["trend_pass"].to_dict()
  72. self.assertEqual(outcome["sse50"], True)
  73. self.assertEqual(outcome["hs300"], True)
  74. self.assertEqual(outcome["star50"], True)
  75. self.assertEqual(outcome["chinext50"], False)
  76. def test_trend_filter_threshold_can_be_raised(self) -> None:
  77. frame = apply_trend_filter(make_signal_input(), min_rules=3)
  78. outcome = frame.set_index("instrument")["trend_pass"].to_dict()
  79. self.assertEqual(outcome["sse50"], True)
  80. self.assertEqual(outcome["hs300"], True)
  81. self.assertEqual(outcome["star50"], False)
  82. self.assertEqual(outcome["chinext50"], False)
  83. def test_ranking_and_scoring_only_selects_trend_pass_members(self) -> None:
  84. signals = build_signal_panel(make_signal_input(), top_n=2).set_index("instrument")
  85. self.assertEqual(signals.loc["sse50", "selection_rank"], 1)
  86. self.assertEqual(signals.loc["hs300", "selection_rank"], 2)
  87. self.assertTrue(pd.isna(signals.loc["chinext50", "selection_rank"]))
  88. self.assertGreater(signals.loc["sse50", "final_score"], signals.loc["hs300", "final_score"])
  89. def test_custom_risk_penalty_can_change_selection_order(self) -> None:
  90. frame = pd.DataFrame(
  91. [
  92. {
  93. "instrument": "high_beta",
  94. "trade_date": pd.Timestamp("2020-01-10"),
  95. "close": 110,
  96. "daily_return": 0.01,
  97. "ret_5d": 0.12,
  98. "ret_10d": 0.12,
  99. "ret_20d": 0.12,
  100. "ret_60d": 0.12,
  101. "ma_20": 100,
  102. "ma_60": 95,
  103. "vol_10d": 0.30,
  104. "vol_20d": 0.30,
  105. },
  106. {
  107. "instrument": "defensive",
  108. "trade_date": pd.Timestamp("2020-01-10"),
  109. "close": 108,
  110. "daily_return": 0.01,
  111. "ret_5d": 0.11,
  112. "ret_10d": 0.11,
  113. "ret_20d": 0.11,
  114. "ret_60d": 0.11,
  115. "ma_20": 100,
  116. "ma_60": 95,
  117. "vol_10d": 0.05,
  118. "vol_20d": 0.05,
  119. },
  120. {
  121. "instrument": "filler_a",
  122. "trade_date": pd.Timestamp("2020-01-10"),
  123. "close": 90,
  124. "daily_return": -0.01,
  125. "ret_5d": 0.02,
  126. "ret_10d": 0.02,
  127. "ret_20d": 0.02,
  128. "ret_60d": 0.02,
  129. "ma_20": 100,
  130. "ma_60": 101,
  131. "vol_10d": 0.10,
  132. "vol_20d": 0.10,
  133. },
  134. {
  135. "instrument": "filler_b",
  136. "trade_date": pd.Timestamp("2020-01-10"),
  137. "close": 89,
  138. "daily_return": -0.01,
  139. "ret_5d": 0.01,
  140. "ret_10d": 0.01,
  141. "ret_20d": 0.01,
  142. "ret_60d": 0.01,
  143. "ma_20": 100,
  144. "ma_60": 101,
  145. "vol_10d": 0.11,
  146. "vol_20d": 0.11,
  147. },
  148. ]
  149. )
  150. low_penalty = build_signal_panel(frame, top_n=1, risk_penalty_multiplier=0.0).set_index("instrument")
  151. high_penalty = build_signal_panel(frame, top_n=1, risk_penalty_multiplier=1.0).set_index("instrument")
  152. self.assertEqual(low_penalty.loc["high_beta", "selection_rank"], 1)
  153. self.assertEqual(high_penalty.loc["defensive", "selection_rank"], 1)
  154. def test_custom_momentum_weights_can_change_selection_order(self) -> None:
  155. frame = pd.DataFrame(
  156. [
  157. {
  158. "instrument": "short_burst",
  159. "trade_date": pd.Timestamp("2020-01-10"),
  160. "close": 110,
  161. "daily_return": 0.01,
  162. "ret_5d": 0.20,
  163. "ret_10d": 0.20,
  164. "ret_20d": 0.05,
  165. "ret_60d": 0.01,
  166. "ma_20": 100,
  167. "ma_60": 95,
  168. "vol_10d": 0.10,
  169. "vol_20d": 0.10,
  170. },
  171. {
  172. "instrument": "long_runner",
  173. "trade_date": pd.Timestamp("2020-01-10"),
  174. "close": 108,
  175. "daily_return": 0.01,
  176. "ret_5d": 0.10,
  177. "ret_10d": 0.10,
  178. "ret_20d": 0.15,
  179. "ret_60d": 0.18,
  180. "ma_20": 100,
  181. "ma_60": 95,
  182. "vol_10d": 0.10,
  183. "vol_20d": 0.10,
  184. },
  185. {
  186. "instrument": "filler_a",
  187. "trade_date": pd.Timestamp("2020-01-10"),
  188. "close": 90,
  189. "daily_return": -0.01,
  190. "ret_5d": 0.01,
  191. "ret_10d": 0.01,
  192. "ret_20d": 0.01,
  193. "ret_60d": 0.01,
  194. "ma_20": 100,
  195. "ma_60": 95,
  196. "vol_10d": 0.10,
  197. "vol_20d": 0.10,
  198. },
  199. {
  200. "instrument": "filler_b",
  201. "trade_date": pd.Timestamp("2020-01-10"),
  202. "close": 89,
  203. "daily_return": -0.01,
  204. "ret_5d": 0.05,
  205. "ret_10d": 0.04,
  206. "ret_20d": 0.03,
  207. "ret_60d": 0.02,
  208. "ma_20": 100,
  209. "ma_60": 95,
  210. "vol_10d": 0.10,
  211. "vol_20d": 0.10,
  212. },
  213. ]
  214. )
  215. default_panel = build_signal_panel(frame, top_n=1, risk_penalty_multiplier=0.0).set_index("instrument")
  216. short_tilt_panel = build_signal_panel(
  217. frame,
  218. top_n=1,
  219. risk_penalty_multiplier=0.0,
  220. momentum_weights={"ret_5d": 0.50, "ret_10d": 0.30, "ret_20d": 0.15, "ret_60d": 0.05},
  221. ).set_index("instrument")
  222. self.assertEqual(default_panel.loc["long_runner", "selection_rank"], 1)
  223. self.assertEqual(short_tilt_panel.loc["short_burst", "selection_rank"], 1)
  224. def test_top1_top2_and_empty_allocation(self) -> None:
  225. base_signals = build_signal_panel(make_signal_input(), top_n=2)
  226. top2 = allocate_weights(base_signals, top_n=2)
  227. top2_weights = top2.set_index("instrument")["target_weight"].to_dict()
  228. self.assertEqual(top2_weights["sse50"], 0.5)
  229. self.assertEqual(top2_weights["hs300"], 0.5)
  230. self.assertEqual(top2["cash_weight"].iloc[0], 0.0)
  231. top1 = allocate_weights(build_signal_panel(make_signal_input(), top_n=1), top_n=1)
  232. top1_weights = top1.set_index("instrument")["target_weight"].to_dict()
  233. self.assertEqual(top1_weights["sse50"], 1.0)
  234. self.assertEqual(sum(top1_weights.values()), 1.0)
  235. empty = make_signal_input()
  236. empty[["close", "ret_20d"]] = [90, -0.10]
  237. empty["ma_20"] = 100
  238. empty["ma_60"] = 110
  239. allocated = allocate_weights(build_signal_panel(empty, top_n=2), top_n=2)
  240. self.assertTrue((allocated["target_weight"] == 0.0).all())
  241. self.assertEqual(allocated["cash_weight"].iloc[0], 1.0)
  242. if __name__ == "__main__":
  243. unittest.main()