Skip to content

fix(risk): stop the position limit being bypassed by a key mismatch (BUG-20) - #24

Merged
sijie-Z merged 1 commit into
mainfrom
fix/risk-ticker-key
Sep 13, 2026
Merged

sijie-Z merged 1 commit into
mainfrom
fix/risk-ticker-key

Conversation

@sijie-Z

@sijie-Z sijie-Z commented Sep 13, 2026

Copy link
Copy Markdown
Owner

P1 — risk-control bypass.

The bypass

Limit checks read order["ticker"]:

ticker = order.get("ticker", "")
current_value = self.positions.get(ticker, {}).get("value", 0)

trading/live_runner.py passed {"code": ...} — the name Order, the positions table and the rest of the codebase use. It resolved to an empty ticker, found no existing position, and re-checked the limit from zero.

Demonstrated with a 49% holding against a 5% single-name limit, and a 10k order:

{"ticker": "600519", ...}  -> approved=False   (weight 50.0% exceeds 5.0%)
{"code":   "600519", ...}  -> approved=True    <- bypassed

The tests never caught it because they pass ticker — the key the function documents, and the one key that worked.

Two callers were wrong

Caller Problem
trading/live_runner.py passed code
strategy/portfolio_orchestrator.py called check_pre_trade(ticker=..., side=...) with keyword arguments against a method taking a single dict, then read check.get('approved') from a return value that is an (approved, breaches) tuple. Both wrong — that path raised rather than checking anything

Fix, in three parts

  1. Accept ticker, symbol or code. All three are names this codebase actually uses for the same thing; risk/realtime_engine.py already accepted ticker/symbol.
  2. Fail closed when none is present. Position and sector limits cannot be evaluated without an identifier, and the old behaviour — silently checking from zero — is the bypass. A risk check must not approve what it cannot evaluate.
  3. Both callers pass the documented shape.

Tests — 5 new, parametrized over the three key names

  • every alias hits the limit check
  • an order with no identifier is blocked
  • an identifier with no position yet is still checked, not rejected — so the fail-closed rule does not over-block

Verified by reverting the guard: 3 of the new tests fail, and the ticker variant passes both ways, because it was the one path that worked.

1339 passed, 1 skipped.

🤖 Generated with Claude Code

…BUG-20)

The limit checks read `order["ticker"]`:

    ticker = order.get("ticker", "")
    current_value = self.positions.get(ticker, {}).get("value", 0)

`trading/live_runner.py` passed `{"code": ...}` -- the name `Order`, the
positions table and the rest of the codebase use. That resolved to an empty
ticker, found no existing position, and re-checked the limit from zero.

Demonstrated with a 49% holding against a 5% single-name limit and a 10k order:

    {"ticker": "600519", ...}  -> approved=False  (weight 50.0% exceeds 5.0%)
    {"code":   "600519", ...}  -> approved=True   <- limit bypassed

The tests never caught it because they pass `ticker`, which is the key the
function documents and the one key that worked.

Two callers were wrong:

  trading/live_runner.py          passed `code`
  strategy/portfolio_orchestrator called check_pre_trade(ticker=..., side=...)
                                  with keyword arguments against a method that
                                  takes a single dict, and then read
                                  `check.get('approved')` from a return value
                                  that is an (approved, breaches) tuple. Both
                                  wrong, so that path raised instead of
                                  checking anything.

Fix, in three parts:

1. `check_pre_trade` accepts `ticker`, `symbol` or `code`. All three are names
   this codebase actually uses for the same thing, so accepting them is the
   honest contract; `risk/realtime_engine.py` already accepted ticker/symbol.
2. It **fails closed** on an order with no identifier at all. Position and
   sector limits cannot be evaluated without one, and the old behaviour --
   silently checking from zero -- is precisely the bypass. Risk checks must
   not approve what they cannot evaluate.
3. Both callers pass the documented shape.

Tests (5 new, parametrized over the three key names):
  - every alias hits the limit check (all three failed before for code/symbol)
  - an order with no identifier is blocked
  - an identifier with no position yet is still checked, not rejected, so the
    fail-closed rule does not over-block

Verified by reverting the guard: 3 of the new tests fail, the `ticker` variant
passes both ways -- it was the one path that worked.

1339 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: de87ee7d-0625-4bd4-8a98-4d4a8a2824b2


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sijie-Z
sijie-Z merged commit daf321e into main Sep 13, 2026
9 checks passed
@sijie-Z
sijie-Z deleted the fix/risk-ticker-key branch September 13, 2026 06:15
@sijie-Z
sijie-Z restored the fix/risk-ticker-key branch September 23, 2026 05:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant