|
19 | 19 |
|
20 | 20 | @dataclass |
21 | 21 | class LintIssue: |
22 | | - rule_id: str # "QC001"–"QC008" |
| 22 | + rule_id: str # "QC001"–"QC009" |
23 | 23 | line: int |
24 | 24 | message: str |
25 | 25 | severity: str # "error" | "warning" |
@@ -386,6 +386,88 @@ def _rule_qc006(code: str, issues: List[LintIssue]) -> str: |
386 | 386 | }) |
387 | 387 |
|
388 | 388 |
|
| 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 | + |
389 | 471 | def _rule_qc008(code: str, issues: List[LintIssue]) -> str: |
390 | 472 | """Warn about self.xxx = ... where xxx is a QCAlgorithm indicator method.""" |
391 | 473 | try: |
@@ -417,14 +499,15 @@ def _rule_qc008(code: str, issues: List[LintIssue]) -> str: |
417 | 499 |
|
418 | 500 | # Rule execution order: case normalization first, then structural fixes, then warnings |
419 | 501 | _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) |
428 | 511 | ] |
429 | 512 |
|
430 | 513 |
|
|
0 commit comments