Skip to content

fix: correct KIS fill parsing and OpenClaw Telegram mirroring#169

Open
robin-watcha wants to merge 1 commit intomainfrom
codex/fix-kis-overseas-side-and-openclaw-telegram
Open

fix: correct KIS fill parsing and OpenClaw Telegram mirroring#169
robin-watcha wants to merge 1 commit intomainfrom
codex/fix-kis-overseas-side-and-openclaw-telegram

Conversation

@robin-watcha
Copy link
Collaborator

@robin-watcha robin-watcha commented Feb 17, 2026

Summary

  • fix KIS websocket execution parsing with market-specific handlers (KR/US)
  • correct overseas H0GSCNI0 side index mapping (SELN_BYOV_CLS at index 4) and filter US execution events by fill_yn == "2"
  • fix domestic compact payload amount parsing by deriving filled_amount = price * qty
  • remove unused OpenClaw request_analysis path and keep alert-style senders (fill/scan/watch)
  • mirror OpenClaw outbound alert messages to Telegram even when OpenClaw delivery fails
  • initialize/shutdown TradeNotifier in websocket monitor runtime

Verification

  • uv run pytest --no-cov tests/test_kis_websocket.py -q -k "overseas or domestic or fill_event"
  • uv run pytest --no-cov tests/test_openclaw_client.py -q
  • uv run pytest --no-cov tests/test_websocket_monitor.py -q
  • uv run ruff check app/services/kis_websocket.py app/services/openclaw_client.py websocket_monitor.py tests/test_kis_websocket.py tests/test_openclaw_client.py tests/test_websocket_monitor.py
  • uv run pyright app/services/kis_websocket.py app/services/openclaw_client.py websocket_monitor.py

Summary by CodeRabbit

  • New Features

    • Added Telegram notification integration to the websocket monitor
    • Enhanced execution event parsing for domestic and overseas market orders
  • Improvements

    • Streamlined client initialization and configuration
    • Notifications now reliably forward to Telegram even if primary delivery fails

@coderabbitai
Copy link

coderabbitai bot commented Feb 17, 2026

📝 Walkthrough

Walkthrough

These changes enhance the KIS WebSocket's execution parsing with separate handlers for overseas and domestic markets, simplify the OpenClawClient API by removing callback_url and request_analysis, adjust notification flows to guarantee Telegram forwarding on OpenClaw failures, and integrate a trade notifier into the WebSocket monitor with proper lifecycle management.

Changes

Cohort / File(s) Summary
KIS WebSocket Execution Parsing
app/services/kis_websocket.py
Added field mappings (OVERSEAS_FILL_FIELDS, DOMESTIC_FILL_FIELDS) and side mappings (OVERSEAS_SIDE_MAP) for execution payloads. Extended _is_execution_event to recognize overseas executions with fill_yn="2". Introduced _parse_overseas_execution and _parse_domestic_execution methods with field index-based parsing and validation logic for symbol, side, price, quantity, and timestamps.
OpenClawClient API Refactoring
app/services/openclaw_client.py
Removed callback_url parameter from constructor, deleted public request_analysis method, removed _build_openclaw_message helper. Modified _send_fill_notification and _send_market_alert to use delivered_to_openclaw flag, moved Telegram mirroring to finally blocks, and return request_id only on successful OpenClaw delivery (else None). Removed json import.
KIS WebSocket Execution Tests
tests/test_kis_websocket.py
Added three new tests: test_parse_message_extracts_overseas_fill_fields_by_index (validates overseas field parsing), test_is_execution_event_rejects_overseas_when_fill_yn_not_two and test_is_execution_event_accepts_overseas_when_fill_yn_two (validate execution event filtering). Updated existing fill field test to assert filled_amount value and filled_at timestamp format.
OpenClaw Client Tests
tests/test_openclaw_client.py
Removed legacy tests for _build_openclaw_message, request_analysis, and retry scenarios. Added new test cases for send_fill_notification, send_scan_alert, and send_watch_alert focusing on Telegram/notifier forwarding when OpenClaw fails. Tests verify message content and notifier interaction patterns.
WebSocket Monitor Notifier Integration
websocket_monitor.py, tests/test_websocket_monitor.py
Added trade notifier initialization in websocket_monitor.main() when telegram_token and telegram_chat_id are configured. Implemented graceful notifier shutdown with error handling. Added test_main_configures_and_shuts_down_trade_notifier to verify notifier configuration (bot_token, chat_ids) and shutdown invocation.

Sequence Diagram(s)

sequenceDiagram
    participant Client as KIS Client
    participant KIS as KIS WebSocket
    participant Parser as Execution Parser
    participant Result as Execution Result

    Client->>KIS: Connect & Listen
    KIS->>KIS: Receive execution message
    KIS->>KIS: Check if execution event (fill_yn="2")
    alt Is Execution Event
        KIS->>Parser: Delegate to market-specific parser
        alt Overseas Market
            Parser->>Parser: Parse by OVERSEAS_FILL_FIELDS indices
            Parser->>Parser: Map side via OVERSEAS_SIDE_MAP
            Parser->>Parser: Validate symbol, side, price, qty, timestamps
        else Domestic Market
            Parser->>Parser: Parse by DOMESTIC_FILL_FIELDS indices
            Parser->>Parser: Validate symbol, side, price, qty, timestamps
        end
        Parser-->>Result: Return execution dict
        KIS-->>Client: Deliver execution
    else Not Execution Event
        KIS-->>Client: Skip message
    end
Loading
sequenceDiagram
    participant Notifier as Fill Event Source
    participant OpenClaw as OpenClaw Service
    participant Telegram as Telegram API
    participant Fallback as Telegram Notifier

    Notifier->>OpenClaw: Send fill notification
    alt OpenClaw Succeeds
        OpenClaw-->>Notifier: Return request_id
        Notifier-->>Notifier: In finally: Log success
    else OpenClaw Fails
        OpenClaw-->>Notifier: Raise exception
        Notifier-->>Fallback: Forward to Telegram notifier
        Fallback->>Telegram: Send formatted message
        Telegram-->>Fallback: Message sent
        Fallback-->>Notifier: Return result
        Notifier-->>Notifier: Return None (not delivered to OpenClaw)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Whiskers twitch with glee so bright,
Overseas and domestic in perfect sight,
When OpenClaw stumbles, Telegram saves the day,
And notifiers spring to life to have their say! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.23% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: KIS fill parsing corrections and OpenClaw Telegram mirroring, matching the core objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/fix-kis-overseas-side-and-openclaw-telegram

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 and usage tips.

@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
21.8% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

@claude
Copy link

claude bot commented Feb 17, 2026

PR Review: KIS Fill Parsing and OpenClaw Telegram Mirroring

Overview

This PR introduces critical fixes for KIS WebSocket execution parsing and improves the OpenClaw integration with Telegram fallback. The changes are well-structured and address real production issues.


✅ Strengths

1. Market-Specific Parsing Logic

The separation of overseas vs domestic execution parsing is a clean architectural improvement:

  • _parse_overseas_execution() - handles US market fills with field index mapping
  • _parse_domestic_execution() - handles KR market fills with field index mapping
  • Both methods properly validate data before returning

2. Correct Field Index Mapping

The fix for SELN_BYOV_CLS at index 4 addresses the core issue. The new constants are clear:

OVERSEAS_FILL_FIELDS = {
    "side": 4,  # Critical fix here
    "symbol": 7,
    "filled_qty": 8,
    "filled_price": 9,
    "filled_at": 10,
    "fill_yn": 12,
}

3. Proper Fill Event Filtering

The fill_yn == "2" check correctly filters for actual fills vs other events:

if tr_code in OVERSEAS_EXECUTION_TR_CODES:
    return str(data.get("fill_yn", "")).strip() == "2"

4. Resilient Telegram Mirroring

The finally block pattern ensures alerts reach Telegram even when OpenClaw fails:

try:
    # OpenClaw delivery attempts
    ...
finally:
    await self._forward_to_telegram(message, alert_type)

This is excellent for reliability.

5. Comprehensive Test Coverage

  • New tests for overseas fill parsing (test_parse_message_extracts_overseas_fill_fields_by_index)
  • Tests for fill_yn filtering logic
  • Tests confirming Telegram fallback behavior
  • All critical paths are covered

🔍 Issues & Suggestions

1. Critical: filled_amount Calculation Missing Validation

Location: app/services/kis_websocket.py:893, 920

"filled_amount": filled_price * filled_qty,

Issue: While filled_price and filled_qty are validated to be > 0, there's no overflow or precision check for the multiplication result.

Recommendation:

  • For KR market with high prices (e.g., 70,000 KRW × 10,000 shares = 700,000,000), this could cause issues
  • Consider using Decimal for currency calculations or at minimum add a sanity check
  • Add a test case with large values to verify behavior

2. Code Duplication: OVERSEAS_SIDE_MAP vs _SIDE_MAP

Location: app/services/kis_websocket.py:133-140, 109-122

The OVERSEAS_SIDE_MAP is a subset of _SIDE_MAP but redefined separately:

OVERSEAS_SIDE_MAP = {
    "01": "ask", "1": "ask", "S": "ask",
    "02": "bid", "2": "bid", "B": "bid",
}

Recommendation:

  • Either document why overseas markets have a restricted set of side codes
  • Or reuse _SIDE_MAP with a comment explaining that overseas only sees numeric/letter codes

3. Error Handling: Silent Failures in Parsing

Location: app/services/kis_websocket.py:864-866, 898-900

Both parsing methods return None on failure without logging:

if len(fields) <= max(OVERSEAS_FILL_FIELDS.values()):
    return None  # Silent failure

Recommendation:

  • Add debug logging when parsing fails due to insufficient fields
  • This would help diagnose protocol changes or unexpected message formats
  • Example: logger.debug("Insufficient fields for overseas fill: expected %d, got %d", ...)

4. Test Coverage: Missing Edge Cases

Location: tests/test_kis_websocket.py

Good coverage, but missing:

  • Test for filled_amount calculation accuracy (especially with floats)
  • Test for empty/whitespace symbol handling
  • Test for negative price/qty (should these be rejected?)
  • Test for very large numbers (overflow scenarios)

5. Type Safety: Optional order_id Handling

Location: app/services/kis_websocket.py:880-882, 904

order_id = fields[2].strip() if len(fields) > 2 else ""
if not order_id:
    order_id = None

Recommendation:

  • This pattern is correct but could be simplified: order_id = fields[2].strip() or None if len(fields) > 2 else None
  • Add type annotation to the return type to make order_id: str | None explicit

6. Performance: Redundant String Operations

Location: app/services/kis_websocket.py:559-561

tr_code = str(data.get("tr_code", ""))
if tr_code in OVERSEAS_EXECUTION_TR_CODES:
    return str(data.get("fill_yn", "")).strip() == "2"

Minor: data.get("fill_yn", "") likely already returns a string, so the str() wrapper may be redundant. If defensive programming is intended, add a comment.

7. Documentation: Missing Docstrings for New Methods

Location: app/services/kis_websocket.py:864, 898

The new _parse_overseas_execution and _parse_domestic_execution methods lack docstrings explaining:

  • Expected field format
  • Return value meaning
  • Why they return None vs raising exceptions

8. Removed Functionality Review Needed

Location: app/services/openclaw_client.py

The removal of request_analysis() and _build_openclaw_message() (117 deletions) is significant.

Recommendation:

  • Confirm with team that this code path is truly unused in production
  • Check if any scheduled tasks or external services call this endpoint
  • Consider deprecation log message if removing recently-active code

🔒 Security Considerations

✅ Good:

  • No new external input handling without validation
  • Token handling remains unchanged and secure
  • No SQL injection or XSS risks introduced

⚠️ Minor:

  • Telegram fallback could potentially leak sensitive data if OpenClaw filters but Telegram doesn't
  • Ensure format_fill_message() properly sanitizes any user-controlled fields

🚀 Performance Considerations

✅ Good:

  • Field index access is O(1)
  • No new N+1 queries or expensive operations
  • Retry logic with exponential backoff is appropriate

💡 Suggestion:

  • The finally block always calls Telegram, even on success
  • Consider: if not delivered_to_openclaw: await self._forward_to_telegram(...) to avoid duplicate notifications

UPDATE: Re-reading the code, I see the intent is to always mirror to Telegram. If this is the desired behavior, add a comment explaining why (e.g., "Mirror all alerts to Telegram for redundancy").


📝 Best Practices

✅ Excellent:

  • Test-driven approach with comprehensive test updates
  • Clean separation of concerns (market-specific parsers)
  • Proper use of constants for magic numbers
  • Good variable naming

💡 Improvements:

  • Add module-level docstring explaining the field index mapping strategy
  • Consider extracting field extraction logic into a helper (DRY principle)
  • Add performance benchmarks if this is a hot path

✅ Verification Checklist

Based on PR description, these commands should pass:

  • pytest tests/test_kis_websocket.py -k "overseas or domestic or fill_event"
  • pytest tests/test_openclaw_client.py
  • pytest tests/test_websocket_monitor.py
  • ruff check (assuming passing)
  • pyright (assuming passing)

🎯 Recommendations Summary

Must Fix Before Merge:

  1. Add debug logging for parsing failures
  2. Document why request_analysis() was removed (commit message or code comment)
  3. Verify Telegram mirroring behavior is intentional (always send vs. only on OpenClaw failure)

Should Fix:

  1. Add edge case tests (overflow, negative values, empty fields)
  2. Add docstrings to new parsing methods
  3. Consider Decimal for currency calculations

Nice to Have:

  1. Simplify OVERSEAS_SIDE_MAP duplication
  2. Add performance comments for hot paths

🎉 Conclusion

This is a high-quality PR that fixes real bugs with a clean implementation. The market-specific parsing approach is architecturally sound, and the Telegram fallback improves system resilience.

The main concerns are around edge case handling and documentation. Once the "Must Fix" items are addressed, this is ready to merge.

Overall Rating: 8.5/10

Great work on the comprehensive test coverage and clean separation of concerns!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
app/services/kis_websocket.py (2)

864-896: order_id hardcoded at index 2 is not in OVERSEAS_FILL_FIELDS.

All other field indices are looked up from OVERSEAS_FILL_FIELDS, but order_id uses a literal fields[2] on Line 880. For consistency and maintainability, consider adding "order_id": 2 to the constant.

Proposed change
 OVERSEAS_FILL_FIELDS = {
+    "order_id": 2,
     "side": 4,
     "symbol": 7,
     "filled_qty": 8,
     "filled_price": 9,
     "filled_at": 10,
     "fill_yn": 12,
 }

Then on Line 880:

-        order_id = fields[2].strip() if len(fields) > 2 else ""
+        order_id = fields[OVERSEAS_FILL_FIELDS["order_id"]].strip() if len(fields) > OVERSEAS_FILL_FIELDS["order_id"] else ""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/kis_websocket.py` around lines 864 - 896, In
_parse_overseas_execution, the order_id is currently read using a hardcoded
fields[2]; add "order_id": 2 to the OVERSEAS_FILL_FIELDS constant and replace
the literal index with fields[OVERSEAS_FILL_FIELDS["order_id"]].strip() (keeping
the existing fallback to None when empty) so all field indices are consistently
sourced from OVERSEAS_FILL_FIELDS.

864-922: Missing docstrings on _parse_overseas_execution and _parse_domestic_execution.

Both methods contain non-trivial field extraction logic tied to KIS API payload formats. As per coding guidelines: "Document complex analysis logic and API integration details in docstrings using Google style format."

Example docstrings
     def _parse_overseas_execution(self, fields: list[str]) -> dict[str, Any] | None:
+        """Parse overseas (US) execution payload fields into a structured dict.
+
+        Uses index-based extraction per KIS H0GSCNI0 TR payload layout.
+        Returns None if the payload has insufficient fields or invalid price/qty.
+
+        Args:
+            fields: Split payload tokens from the WebSocket message.
+
+        Returns:
+            Parsed execution dict with symbol, side, price, qty, amount,
+            fill_yn, and timestamp; or None on validation failure.
+        """
     def _parse_domestic_execution(self, fields: list[str]) -> dict[str, Any] | None:
+        """Parse domestic (KR) execution payload fields into a structured dict.
+
+        Uses index-based extraction per KIS H0STCNI0 TR payload layout.
+        Returns None if the payload has insufficient fields or invalid price/qty.
+
+        Args:
+            fields: Split payload tokens from the WebSocket message.
+
+        Returns:
+            Parsed execution dict with symbol, side, price, qty, amount,
+            and timestamp; or None on validation failure.
+        """
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/kis_websocket.py` around lines 864 - 922, Add Google-style
docstrings to both _parse_overseas_execution and _parse_domestic_execution
describing their purpose (parsing KIS execution/fill payloads), parameters
(fields: list[str]) and return type (dict[str, Any] | None), plus the validation
rules (required indices, non-empty symbol, positive filled_price/filled_qty,
fill_yn handling for overseas) and mapping behavior (use of
OVERSEAS_FILL_FIELDS, DOMESTIC_FILL_FIELDS, OVERSEAS_SIDE_MAP, _SIDE_MAP).
Mention the helper functions used (_to_float, _extract_timestamp) and which
fields they apply to, include a short example input payload snippet and example
return value, and note that None is returned on invalid/insufficient data; keep
docstrings concise and in Google style.
app/services/openclaw_client.py (1)

88-106: The break on Line 106 is redundant with tenacity's AsyncRetrying.

After a successful pass through with attempt: (no exception raised), tenacity's async for loop naturally terminates. The explicit break doesn't cause harm but is unnecessary.

Proposed cleanup
                     logger.info(
                         "OpenClaw fill notification sent: request_id=%s symbol=%s account=%s",
                         request_id,
                         normalized_order.symbol,
                         normalized_order.account,
                     )
                     delivered_to_openclaw = True
-                    break
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/openclaw_client.py` around lines 88 - 106, The explicit break
inside the AsyncRetrying loop is redundant; remove the break statement in the
async for attempt in AsyncRetrying(...) block and let tenacity naturally exit
the loop on success, but keep the delivered_to_openclaw = True assignment (and
the logger.info call) so the success state is preserved; locate these in the
same block that uses httpx.AsyncClient, res.raise_for_status(), and the with
attempt: context to make the change.
tests/test_websocket_monitor.py (1)

283-321: Missing pytest category marker on the new test.

The existing tests in this class don't have explicit markers either, but the coding guidelines require @pytest.mark.unit, @pytest.mark.integration, or @pytest.mark.slow on all tests. Consider adding @pytest.mark.unit to the class or to this test. As per coding guidelines: "Use pytest markers (@pytest.mark.unit, @pytest.mark.integration, @pytest.mark.slow) to categorize tests".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_websocket_monitor.py` around lines 283 - 321, The new async test
test_main_configures_and_shuts_down_trade_notifier is missing a pytest category
marker; add `@pytest.mark.unit` (or place `@pytest.mark.unit` on the containing test
class) directly above the test coroutine definition to satisfy the project's
test categorization guideline and ensure pytest recognizes its category; keep
the existing pytest.mark.asyncio decorator and ensure pytest is
imported/available where the test is defined.
websocket_monitor.py (1)

306-310: Shutdown always attempts notifier cleanup regardless of whether it was configured — verify this is intentional.

The finally block unconditionally calls get_trade_notifier().shutdown(), even when telegram settings are absent and configure() was never called. From the shutdown() implementation this is safe (it's a no-op when _http_client is None), but worth confirming the singleton factory never raises on first access here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@websocket_monitor.py` around lines 306 - 310, The shutdown path should not
unconditionally invoke get_trade_notifier() because the factory may raise or the
notifier may never have been configured; change the finally block to safely
obtain and validate the notifier before calling shutdown: attempt to call
get_trade_notifier() inside a guarded try/except, and only await
trade_notifier.shutdown() if the call succeeded and the returned object
indicates it was configured (e.g., getattr(trade_notifier, "_http_client", None)
is not None or a new is_trade_notifier_configured() helper returns True); log a
debug/warning if the factory call fails and skip shutdown instead of assuming a
valid notifier.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@app/services/kis_websocket.py`:
- Around line 864-896: In _parse_overseas_execution, the order_id is currently
read using a hardcoded fields[2]; add "order_id": 2 to the OVERSEAS_FILL_FIELDS
constant and replace the literal index with
fields[OVERSEAS_FILL_FIELDS["order_id"]].strip() (keeping the existing fallback
to None when empty) so all field indices are consistently sourced from
OVERSEAS_FILL_FIELDS.
- Around line 864-922: Add Google-style docstrings to both
_parse_overseas_execution and _parse_domestic_execution describing their purpose
(parsing KIS execution/fill payloads), parameters (fields: list[str]) and return
type (dict[str, Any] | None), plus the validation rules (required indices,
non-empty symbol, positive filled_price/filled_qty, fill_yn handling for
overseas) and mapping behavior (use of OVERSEAS_FILL_FIELDS,
DOMESTIC_FILL_FIELDS, OVERSEAS_SIDE_MAP, _SIDE_MAP). Mention the helper
functions used (_to_float, _extract_timestamp) and which fields they apply to,
include a short example input payload snippet and example return value, and note
that None is returned on invalid/insufficient data; keep docstrings concise and
in Google style.

In `@app/services/openclaw_client.py`:
- Around line 88-106: The explicit break inside the AsyncRetrying loop is
redundant; remove the break statement in the async for attempt in
AsyncRetrying(...) block and let tenacity naturally exit the loop on success,
but keep the delivered_to_openclaw = True assignment (and the logger.info call)
so the success state is preserved; locate these in the same block that uses
httpx.AsyncClient, res.raise_for_status(), and the with attempt: context to make
the change.

In `@tests/test_websocket_monitor.py`:
- Around line 283-321: The new async test
test_main_configures_and_shuts_down_trade_notifier is missing a pytest category
marker; add `@pytest.mark.unit` (or place `@pytest.mark.unit` on the containing test
class) directly above the test coroutine definition to satisfy the project's
test categorization guideline and ensure pytest recognizes its category; keep
the existing pytest.mark.asyncio decorator and ensure pytest is
imported/available where the test is defined.

In `@websocket_monitor.py`:
- Around line 306-310: The shutdown path should not unconditionally invoke
get_trade_notifier() because the factory may raise or the notifier may never
have been configured; change the finally block to safely obtain and validate the
notifier before calling shutdown: attempt to call get_trade_notifier() inside a
guarded try/except, and only await trade_notifier.shutdown() if the call
succeeded and the returned object indicates it was configured (e.g.,
getattr(trade_notifier, "_http_client", None) is not None or a new
is_trade_notifier_configured() helper returns True); log a debug/warning if the
factory call fails and skip shutdown instead of assuming a valid notifier.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant