Skip to content

Commit 4ca2988

Browse files
SL-Marclaude
andcommitted
Add QC010 (reserved attrs) + QC009 loop pattern for forex/crypto
QC010: Detect self.alpha, self.universe, etc. assignments that crash QCAlgorithm's C#-Python interop with "error return without exception set". Auto-fixes by renaming self.X → self._X in all references. QC009: Now also detects the loop pattern where forex/crypto tickers are in a list variable and add_equity() is called in a for-loop. Classifies the list by majority vote and fixes to add_forex/add_crypto. 54 tests passing (8 new: 3 QC009 loop + 5 QC010). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c6c908b commit 4ca2988

2 files changed

Lines changed: 197 additions & 2 deletions

File tree

quantcoder/core/qc_linter.py

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
@dataclass
2121
class 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+
439462
def _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

tests/test_qc_linter.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -437,6 +437,102 @@ def test_multiple_pairs_all_fixed(self):
437437
assert result.code.count("self.add_forex") == 3
438438
assert "self.add_equity" not in result.code
439439

440+
def test_loop_pattern_forex(self):
441+
"""Ticker list + for loop with add_equity → add_forex."""
442+
code = (
443+
"symbols = [\"EURGBP\", \"EURUSD\", \"EURJPY\", \"USDJPY\"]\n"
444+
"for symbol in symbols:\n"
445+
" self.add_equity(symbol, Resolution.TICK)\n"
446+
)
447+
result = lint_qc_code(code)
448+
assert result.had_fixes
449+
assert "self.add_forex(symbol" in result.code
450+
assert "self.add_equity" not in result.code
451+
452+
def test_loop_pattern_crypto(self):
453+
"""Ticker list + for loop with add_equity → add_crypto."""
454+
code = (
455+
"coins = [\"BTCUSD\", \"ETHUSD\", \"SOLUSD\"]\n"
456+
"for c in coins:\n"
457+
" self.add_equity(c, Resolution.DAILY)\n"
458+
)
459+
result = lint_qc_code(code)
460+
assert result.had_fixes
461+
assert "self.add_crypto(c" in result.code
462+
463+
def test_loop_pattern_equity_unchanged(self):
464+
"""Equity ticker list should not be changed."""
465+
code = (
466+
"tickers = [\"AAPL\", \"MSFT\", \"GOOGL\"]\n"
467+
"for t in tickers:\n"
468+
" self.add_equity(t, Resolution.DAILY)\n"
469+
)
470+
result = lint_qc_code(code)
471+
qc009_issues = _issues_by_rule(result, "QC009")
472+
assert len(qc009_issues) == 0
473+
assert "self.add_equity(t" in result.code
474+
475+
476+
# ---------------------------------------------------------------------------
477+
# QC010 — Reserved QCAlgorithm attribute names
478+
# ---------------------------------------------------------------------------
479+
480+
class TestQC010ReservedAttrs:
481+
"""self.alpha = ... → self._alpha = ... (reserved C# property)."""
482+
483+
def test_alpha_renamed(self):
484+
code = (
485+
"class Algo(QCAlgorithm):\n"
486+
" def initialize(self):\n"
487+
" self.alpha = 0.5\n"
488+
" def on_data(self, data):\n"
489+
" x = self.alpha * price\n"
490+
)
491+
result = lint_qc_code(code)
492+
assert result.had_fixes
493+
assert "self._alpha = 0.5" in result.code
494+
assert "self._alpha * price" in result.code
495+
assert "self.alpha" not in result.code
496+
497+
def test_universe_renamed(self):
498+
code = (
499+
"class Algo(QCAlgorithm):\n"
500+
" def initialize(self):\n"
501+
" self.universe = ['SPY']\n"
502+
)
503+
result = lint_qc_code(code)
504+
assert result.had_fixes
505+
assert "self._universe" in result.code
506+
507+
def test_no_assignment_no_change(self):
508+
"""Reading a framework property should not trigger a rename."""
509+
code = "x = self.alpha\n"
510+
result = lint_qc_code(code)
511+
qc010_issues = _issues_by_rule(result, "QC010")
512+
assert len(qc010_issues) == 0
513+
514+
def test_underscore_prefix_unchanged(self):
515+
code = (
516+
"class Algo(QCAlgorithm):\n"
517+
" def initialize(self):\n"
518+
" self._alpha = 0.5\n"
519+
)
520+
result = lint_qc_code(code)
521+
qc010_issues = _issues_by_rule(result, "QC010")
522+
assert len(qc010_issues) == 0
523+
524+
def test_alpha_value_not_matched(self):
525+
"""self.alpha_value should NOT be renamed to self._alpha_value."""
526+
code = (
527+
"class Algo(QCAlgorithm):\n"
528+
" def initialize(self):\n"
529+
" self.alpha_value = 0.5\n"
530+
)
531+
result = lint_qc_code(code)
532+
qc010_issues = _issues_by_rule(result, "QC010")
533+
assert len(qc010_issues) == 0
534+
assert "self.alpha_value" in result.code
535+
440536

441537
class TestComposition:
442538
"""Multiple rules apply to the same code."""

0 commit comments

Comments
 (0)