1919
2020@dataclass
2121class LintIssue :
22- rule_id : str # "QC001"–"QC009 "
22+ rule_id : str # "QC001"–"QC010 "
2323 line : int
2424 message : str
2525 severity : str # "error" | "warning"
@@ -436,9 +436,33 @@ def _is_crypto_pair(ticker: str) -> bool:
436436 return base in _CRYPTO_BASES
437437
438438
439+ def _classify_tickers (tickers : list ) -> str :
440+ """Classify a list of tickers: 'forex', 'crypto', or 'equity'."""
441+ forex_count = sum (1 for t in tickers if _is_forex_pair (t ))
442+ crypto_count = sum (1 for t in tickers if _is_crypto_pair (t ))
443+ if forex_count > len (tickers ) // 2 :
444+ return "forex"
445+ if crypto_count > len (tickers ) // 2 :
446+ return "crypto"
447+ return "equity"
448+
449+
450+ # Pattern: var = ["EURUSD", "GBPJPY", ...] (list of string literals)
451+ _TICKER_LIST = re .compile (
452+ r'(\w+)\s*=\s*\[((?:\s*["\'][A-Z]{3,10}(?:/[A-Z]{2,5})?["\']\s*,?\s*)+)\]'
453+ )
454+
455+ # Pattern: for var in list_var: ... self.add_equity(var
456+ _LOOP_ADD_EQUITY = re .compile (
457+ r'for\s+(\w+)\s+in\s+(\w+)\s*:.*?self\.add_equity\s*\(\s*\1\b' ,
458+ re .DOTALL ,
459+ )
460+
461+
439462def _rule_qc009 (code : str , issues : List [LintIssue ]) -> str :
440463 """Fix add_equity() used with forex or crypto tickers."""
441- # Process each match from right to left to preserve offsets
464+
465+ # --- Pattern 1: direct inline ticker ---
442466 matches = list (_ADD_EQUITY_TICKER .finditer (code ))
443467 for m in reversed (matches ):
444468 ticker = m .group (3 )
@@ -465,6 +489,39 @@ def _rule_qc009(code: str, issues: List[LintIssue]) -> str:
465489 ))
466490 code = code [:m .start ()] + new + code [m .end ():]
467491
492+ # --- Pattern 2: ticker list variable + loop with add_equity(var) ---
493+ # Find all ticker list declarations and classify them
494+ list_vars : dict = {} # var_name -> asset_class
495+ for m in _TICKER_LIST .finditer (code ):
496+ var_name = m .group (1 )
497+ raw = m .group (2 )
498+ tickers = re .findall (r'["\']([A-Z]{3,10}(?:/[A-Z]{2,5})?)["\']' , raw )
499+ asset_class = _classify_tickers (tickers )
500+ if asset_class != "equity" :
501+ list_vars [var_name ] = asset_class
502+
503+ # Find loops that iterate over a classified list and call add_equity
504+ if list_vars :
505+ for m in _LOOP_ADD_EQUITY .finditer (code ):
506+ loop_var = m .group (1 )
507+ list_var = m .group (2 )
508+ asset_class = list_vars .get (list_var )
509+ if not asset_class :
510+ continue
511+ replacement = "self.add_forex" if asset_class == "forex" else "self.add_crypto"
512+ # Replace add_equity with the correct method in this match
513+ pat = re .compile (r'self\.add_equity(\s*\(\s*' + re .escape (loop_var ) + r'\b)' )
514+ for sub_m in pat .finditer (code ):
515+ lineno = code [:sub_m .start ()].count ('\n ' ) + 1
516+ issues .append (LintIssue (
517+ rule_id = "QC009" , line = lineno ,
518+ message = f"Ticker list { list_var } contains { asset_class } pairs — "
519+ f"use { replacement } (), not self.add_equity()" ,
520+ severity = "error" , fixed = True ,
521+ original = sub_m .group (), replacement = replacement + sub_m .group (1 ),
522+ ))
523+ code = pat .sub (replacement + r'\1' , code )
524+
468525 return code
469526
470527
@@ -493,6 +550,47 @@ def _rule_qc008(code: str, issues: List[LintIssue]) -> str:
493550 return code
494551
495552
553+ # ---------------------------------------------------------------------------
554+ # QC010 — Reserved QCAlgorithm attribute names (auto-fix)
555+ # ---------------------------------------------------------------------------
556+
557+ # C#-backed properties that crash with "error return without exception set"
558+ # when assigned from Python. Common LLM parameter names that collide.
559+ _RESERVED_ATTRS = frozenset ({
560+ "alpha" , # Alpha framework model
561+ "universe" , # Universe selection model
562+ "execution" , # Execution model
563+ "name" , # Algorithm.Name (read-only)
564+ "risk_management" , # Risk management model
565+ "portfolio_construction" , # Portfolio construction model
566+ })
567+
568+
569+ def _rule_qc010 (code : str , issues : List [LintIssue ]) -> str :
570+ """Rename self.RESERVED = ... assignments and all references to self._RESERVED."""
571+ for attr in _RESERVED_ATTRS :
572+ # Check if there's an assignment to self.<attr>
573+ assign_pat = re .compile (r'self\.' + re .escape (attr ) + r'\s*=' )
574+ if not assign_pat .search (code ):
575+ continue
576+
577+ # Replace ALL occurrences of self.<attr> (assignments + reads)
578+ # Word boundary after to avoid self.alpha_value → self._alpha_value
579+ usage_pat = re .compile (r'self\.' + re .escape (attr ) + r'(?![a-zA-Z0-9_])' )
580+ for m in usage_pat .finditer (code ):
581+ lineno = code [:m .start ()].count ('\n ' ) + 1
582+ issues .append (LintIssue (
583+ rule_id = "QC010" , line = lineno ,
584+ message = f"self.{ attr } is a reserved QCAlgorithm property — "
585+ f"renamed to self._{ attr } " ,
586+ severity = "error" , fixed = True ,
587+ original = f"self.{ attr } " , replacement = f"self._{ attr } " ,
588+ ))
589+ code = usage_pat .sub (f"self._{ attr } " , code )
590+
591+ return code
592+
593+
496594# ---------------------------------------------------------------------------
497595# Public entry point
498596# ---------------------------------------------------------------------------
@@ -502,6 +600,7 @@ def _rule_qc008(code: str, issues: List[LintIssue]) -> str:
502600 _rule_qc001 , # PascalCase → snake_case (must run first)
503601 _rule_qc007 , # Resolution casing
504602 _rule_qc009 , # Wrong asset class API (runs after QC001 normalizes add_equity)
603+ _rule_qc010 , # Reserved QCAlgorithm attribute names
505604 _rule_qc004 , # Action() wrapper
506605 _rule_qc002 , # len() on RollingWindow
507606 _rule_qc003 , # .Values on RollingWindow
0 commit comments