Skip to content

Commit c6c908b

Browse files
SL-Marclaude
andcommitted
Add QC009 lint rule: fix wrong asset class API for forex/crypto pairs
- Detect forex tickers (EUR/USD, EURUSD, GBP/CHF, etc.) passed to add_equity() and auto-fix to add_forex() - Detect crypto tickers (BTCUSD, ETH/USD, etc.) passed to add_equity() and auto-fix to add_crypto() - Supports both slash and no-slash formats, single and double quotes - Runs after QC001 so PascalCase AddEquity is already normalized - 9 new tests (46 total linter tests, all passing) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a4bb677 commit c6c908b

2 files changed

Lines changed: 169 additions & 9 deletions

File tree

quantcoder/core/qc_linter.py

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

2020
@dataclass
2121
class LintIssue:
22-
rule_id: str # "QC001"–"QC008"
22+
rule_id: str # "QC001"–"QC009"
2323
line: int
2424
message: str
2525
severity: str # "error" | "warning"
@@ -386,6 +386,88 @@ def _rule_qc006(code: str, issues: List[LintIssue]) -> str:
386386
})
387387

388388

389+
# ---------------------------------------------------------------------------
390+
# QC009 — Wrong asset class API (e.g. add_equity for forex pairs)
391+
# ---------------------------------------------------------------------------
392+
393+
# ISO 4217 major currency codes used in forex pairs
394+
_FOREX_CURRENCIES = frozenset({
395+
"EUR", "USD", "GBP", "JPY", "CHF", "AUD", "NZD", "CAD",
396+
"SEK", "NOK", "DKK", "SGD", "HKD", "ZAR", "MXN", "TRY",
397+
"PLN", "CZK", "HUF", "INR", "CNY", "CNH", "KRW", "BRL",
398+
})
399+
400+
# Common crypto base tickers
401+
_CRYPTO_BASES = frozenset({
402+
"BTC", "ETH", "LTC", "XRP", "BCH", "ADA", "DOT", "LINK",
403+
"SOL", "AVAX", "DOGE", "SHIB", "MATIC", "UNI", "AAVE",
404+
"ATOM", "XLM", "ALGO", "FIL", "NEAR",
405+
})
406+
407+
# Matches: self.add_equity("EUR/USD" ...) or self.add_equity("EURUSD" ...)
408+
# Captures the full call including open paren, quote char, ticker, and quote char
409+
_ADD_EQUITY_TICKER = re.compile(
410+
r'(self\.add_equity\s*\(\s*)(["\'])([A-Z]{3,10}(?:/[A-Z]{2,5})?)\2'
411+
)
412+
413+
414+
def _is_forex_pair(ticker: str) -> bool:
415+
"""Check if ticker looks like a forex pair (e.g. EUR/USD, EURUSD)."""
416+
if "/" in ticker:
417+
parts = ticker.split("/")
418+
return (len(parts) == 2
419+
and parts[0] in _FOREX_CURRENCIES
420+
and parts[1] in _FOREX_CURRENCIES)
421+
# No slash: EURUSD style (6 chars, both halves are currencies)
422+
if len(ticker) == 6:
423+
return (ticker[:3] in _FOREX_CURRENCIES
424+
and ticker[3:] in _FOREX_CURRENCIES)
425+
return False
426+
427+
428+
def _is_crypto_pair(ticker: str) -> bool:
429+
"""Check if ticker looks like a crypto pair (e.g. BTCUSD, BTC/USD)."""
430+
if "/" in ticker:
431+
base = ticker.split("/")[0]
432+
elif len(ticker) >= 6:
433+
base = ticker[:-3] # assume 3-char quote (USD, EUR, etc.)
434+
else:
435+
return False
436+
return base in _CRYPTO_BASES
437+
438+
439+
def _rule_qc009(code: str, issues: List[LintIssue]) -> str:
440+
"""Fix add_equity() used with forex or crypto tickers."""
441+
# Process each match from right to left to preserve offsets
442+
matches = list(_ADD_EQUITY_TICKER.finditer(code))
443+
for m in reversed(matches):
444+
ticker = m.group(3)
445+
if _is_forex_pair(ticker):
446+
lineno = code[:m.start()].count('\n') + 1
447+
old = m.group()
448+
new = m.group().replace("self.add_equity", "self.add_forex", 1)
449+
issues.append(LintIssue(
450+
rule_id="QC009", line=lineno,
451+
message=f"Forex pair {ticker} should use self.add_forex(), not self.add_equity()",
452+
severity="error", fixed=True,
453+
original=old, replacement=new,
454+
))
455+
code = code[:m.start()] + new + code[m.end():]
456+
elif _is_crypto_pair(ticker):
457+
lineno = code[:m.start()].count('\n') + 1
458+
old = m.group()
459+
new = m.group().replace("self.add_equity", "self.add_crypto", 1)
460+
issues.append(LintIssue(
461+
rule_id="QC009", line=lineno,
462+
message=f"Crypto pair {ticker} should use self.add_crypto(), not self.add_equity()",
463+
severity="error", fixed=True,
464+
original=old, replacement=new,
465+
))
466+
code = code[:m.start()] + new + code[m.end():]
467+
468+
return code
469+
470+
389471
def _rule_qc008(code: str, issues: List[LintIssue]) -> str:
390472
"""Warn about self.xxx = ... where xxx is a QCAlgorithm indicator method."""
391473
try:
@@ -417,14 +499,15 @@ def _rule_qc008(code: str, issues: List[LintIssue]) -> str:
417499

418500
# Rule execution order: case normalization first, then structural fixes, then warnings
419501
_RULES = [
420-
_rule_qc001,
421-
_rule_qc007,
422-
_rule_qc004,
423-
_rule_qc002,
424-
_rule_qc003,
425-
_rule_qc005,
426-
_rule_qc006,
427-
_rule_qc008,
502+
_rule_qc001, # PascalCase → snake_case (must run first)
503+
_rule_qc007, # Resolution casing
504+
_rule_qc009, # Wrong asset class API (runs after QC001 normalizes add_equity)
505+
_rule_qc004, # Action() wrapper
506+
_rule_qc002, # len() on RollingWindow
507+
_rule_qc003, # .Values on RollingWindow
508+
_rule_qc005, # History as Slice (warning)
509+
_rule_qc006, # history() in on_data() (warning)
510+
_rule_qc008, # Indicator shadowing (warning)
428511
]
429512

430513

tests/test_qc_linter.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,83 @@ def test_multiple_indicators_warned(self):
361361
# Integration / composition tests
362362
# ---------------------------------------------------------------------------
363363

364+
# ---------------------------------------------------------------------------
365+
# QC009 — Wrong asset class API
366+
# ---------------------------------------------------------------------------
367+
368+
class TestQC009AssetClass:
369+
"""add_equity() with forex/crypto tickers → add_forex()/add_crypto()."""
370+
371+
def test_forex_slash_fixed(self):
372+
code = (
373+
"class Algo(QCAlgorithm):\n"
374+
" def initialize(self):\n"
375+
" self.add_equity(\"EUR/USD\", Resolution.TICK)\n"
376+
" self.add_equity(\"USD/JPY\", Resolution.TICK)\n"
377+
)
378+
result = lint_qc_code(code)
379+
assert result.had_fixes
380+
assert "self.add_forex(\"EUR/USD\"" in result.code
381+
assert "self.add_forex(\"USD/JPY\"" in result.code
382+
assert "self.add_equity" not in result.code
383+
384+
def test_forex_noslash_fixed(self):
385+
code = "self.add_equity(\"EURUSD\", Resolution.MINUTE)\n"
386+
result = lint_qc_code(code)
387+
assert result.had_fixes
388+
assert "self.add_forex(\"EURUSD\"" in result.code
389+
390+
def test_forex_single_quotes(self):
391+
code = "self.add_equity('GBP/CHF', Resolution.DAILY)\n"
392+
result = lint_qc_code(code)
393+
assert result.had_fixes
394+
assert "self.add_forex('GBP/CHF'" in result.code
395+
396+
def test_crypto_fixed(self):
397+
code = "self.add_equity(\"BTCUSD\", Resolution.DAILY)\n"
398+
result = lint_qc_code(code)
399+
assert result.had_fixes
400+
assert "self.add_crypto(\"BTCUSD\"" in result.code
401+
402+
def test_crypto_slash_fixed(self):
403+
code = "self.add_equity(\"ETH/USD\", Resolution.HOUR)\n"
404+
result = lint_qc_code(code)
405+
assert result.had_fixes
406+
assert "self.add_crypto(\"ETH/USD\"" in result.code
407+
408+
def test_equity_ticker_unchanged(self):
409+
code = "self.add_equity(\"SPY\", Resolution.DAILY)\n"
410+
result = lint_qc_code(code)
411+
qc009_issues = _issues_by_rule(result, "QC009")
412+
assert len(qc009_issues) == 0
413+
assert "self.add_equity(\"SPY\"" in result.code
414+
415+
def test_already_add_forex_unchanged(self):
416+
code = "self.add_forex(\"EUR/USD\", Resolution.TICK)\n"
417+
result = lint_qc_code(code)
418+
qc009_issues = _issues_by_rule(result, "QC009")
419+
assert len(qc009_issues) == 0
420+
421+
def test_pascalcase_addequity_forex_chain(self):
422+
"""QC001 normalizes AddEquity first, then QC009 fixes to add_forex."""
423+
code = "self.AddEquity(\"EUR/GBP\", Resolution.Daily)\n"
424+
result = lint_qc_code(code)
425+
assert "self.add_forex(\"EUR/GBP\"" in result.code
426+
assert "Resolution.DAILY" in result.code
427+
428+
def test_multiple_pairs_all_fixed(self):
429+
code = (
430+
"for pair in ['EUR/USD', 'GBP/JPY', 'AUD/NZD']:\n"
431+
" pass\n"
432+
"self.add_equity(\"EUR/USD\", Resolution.TICK)\n"
433+
"self.add_equity(\"GBP/JPY\", Resolution.TICK)\n"
434+
"self.add_equity(\"AUD/NZD\", Resolution.TICK)\n"
435+
)
436+
result = lint_qc_code(code)
437+
assert result.code.count("self.add_forex") == 3
438+
assert "self.add_equity" not in result.code
439+
440+
364441
class TestComposition:
365442
"""Multiple rules apply to the same code."""
366443

0 commit comments

Comments
 (0)