fix(strategy): use word-boundary token matching for short crypto tickers to prevent false positives
CI/CD / build-and-push (push) Successful in 8s

Substring matching over question_lower flagged non-crypto markets as crypto:
'dissolved' matched 'sol', 'Canada' matched 'ada', 'Seth' matched 'eth'.
Those false flags armed the BTC-dominance signal (btc_dom_lo=+0.06 observed
on politics markets in production).

Short tickers (btc, eth, sol, xrp, doge, ltc, bnb, ada, avax) now go through
has_token(), which requires non-alphanumeric boundaries so 'ETH', '$ETH' and
'ETH/USD' still match. Long unambiguous names (bitcoin, ethereum, solana,
cardano, ...) remain substring checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
chemavx
2026-06-11 16:34:09 +00:00
co-authored by Claude Fable 5
parent 4002f03d0c
commit f5ac302a86
2 changed files with 186 additions and 6 deletions
+27 -6
View File
@@ -13,6 +13,7 @@ Polymarket might reflect in a slow-moving order book.
import logging import logging
import math import math
import os import os
import re
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -159,6 +160,21 @@ def _days_to_resolution(end_date: str) -> int:
return 30 return 30
def has_token(text: str, token: str) -> bool:
"""
True if `token` appears in `text` as a standalone word.
Short crypto tickers (eth, sol, ada, …) must NOT match inside ordinary
words — "Seth", "dissolved", "Canada" — but must still match the usual
market phrasings: "ETH", "$ETH", "ETH/USD", "SOL reach $200". Boundaries
are any non-alphanumeric character (or start/end of string), so "$" and
"/" delimit correctly.
"""
return re.search(
rf"(?<![A-Za-z0-9]){re.escape(token)}(?![A-Za-z0-9])", text, re.IGNORECASE
) is not None
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Phase 3 — GNews priority scoring # Phase 3 — GNews priority scoring
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -345,13 +361,18 @@ class BayesianStrategy:
"below", "under", "less than", "lower", "drop", "below", "under", "less than", "lower", "drop",
]) ])
is_btc = "btc" in question_lower or "bitcoin" in question_lower # Short tickers need word boundaries: "Seth" contains "eth",
is_eth = "eth" in question_lower or "ethereum" in question_lower # "dissolved" contains "sol", "Canada" contains "ada". Long
is_sol = "sol" in question_lower or "solana" in question_lower # unambiguous names (bitcoin, ethereum, …) stay as substrings.
is_xrp = "xrp" in question_lower or "ripple" in question_lower is_btc = has_token(question_lower, "btc") or "bitcoin" in question_lower
is_doge = "doge" in question_lower or "dogecoin" in question_lower is_eth = has_token(question_lower, "eth") or "ethereum" in question_lower
is_sol = has_token(question_lower, "sol") or "solana" in question_lower
is_xrp = has_token(question_lower, "xrp") or "ripple" in question_lower
is_doge = has_token(question_lower, "doge") or "dogecoin" in question_lower
is_altcoin = is_sol or is_xrp or is_doge or any( is_altcoin = is_sol or is_xrp or is_doge or any(
w in question_lower for w in ["ltc", "litecoin", "bnb", "ada", "cardano", "avax", "avalanche"] has_token(question_lower, t) for t in ["ltc", "bnb", "ada", "avax"]
) or any(
w in question_lower for w in ["litecoin", "cardano", "avalanche"]
) )
is_general_crypto = any( is_general_crypto = any(
w in question_lower for w in ["crypto", "market cap", "total market", "altcoin", "defi"] w in question_lower for w in ["crypto", "market cap", "total market", "altcoin", "defi"]
+159
View File
@@ -0,0 +1,159 @@
"""
Tests for FASE 4 — crypto ticker detection must use word boundaries.
Regression: short tickers were detected with substring matching over
question_lower, so non-crypto markets triggered crypto flags:
"Israeli parliament dissolved" contains "sol" → is_sol / is_altcoin
"Will Canada win Group B" contains "ada" → is_altcoin
"Will Seth Moulton be the nominee" contains "eth" → is_eth
Those flags armed the BTC-dominance signal (btc_dom_lo=+0.06 observed in
production on politics markets). The fix routes short tickers (btc, eth,
sol, xrp, doge, ltc, bnb, ada, avax) through has_token(), which requires
non-alphanumeric boundaries; long unambiguous names (bitcoin, ethereum,
solana, cardano, …) remain substrings.
The is_* flags are internal to evaluate(), so the integration tests assert
on btc_dom_lo parsed from the structured audit log (same technique as
test_bayesian_macro_signals.py), with btc_dominance=60 so the signal fires
whenever an ETH/altcoin flag is set.
"""
import asyncio
import logging
import re
import pytest
from bot.data.external import ExternalSignals
from bot.data.polymarket import Market
from bot.strategy.bayesian import BayesianStrategy, has_token
BTC_DOM_RE = re.compile(r"btc_dom_lo=([+-]\d+\.\d+)")
def _make_market(question: str, category: str) -> Market:
return Market(
id="mkt-test-1",
condition_id="cond-test-1",
question=question,
yes_token_id="yes-tok",
no_token_id="no-tok",
yes_price=0.50,
no_price=0.50,
volume_24h=50_000.0,
end_date="2026-07-15T00:00:00Z",
active=True,
category=category,
)
def _make_signals() -> ExternalSignals:
# btc_dominance=60 (>55) arms the BTC-dominance signal for any market
# flagged as ETH / altcoin / general-crypto.
return ExternalSignals(
btc_price=100_000.0,
btc_change_24h=10.0,
eth_price=4_000.0,
eth_change_24h=8.0,
btc_dominance=60.0,
fear_greed_index=80,
fear_greed_label="greed",
total_market_cap_change=5.0,
valid=True,
)
def _evaluate_and_parse_btc_dom(question: str, category: str, caplog) -> float:
"""Run BayesianStrategy.evaluate and return btc_dom_lo from the audit log."""
strategy = BayesianStrategy(news=None, manifold=None, db=None)
market = _make_market(question, category)
with caplog.at_level(logging.INFO, logger="bot.strategy.bayesian"):
asyncio.run(
strategy.evaluate(market, _make_signals(), occupied_families=set())
)
for record in caplog.records:
m = BTC_DOM_RE.search(record.getMessage())
if m:
return float(m.group(1))
pytest.fail(
"No SKIP_EDGE_NET/TRADE log line with btc_dom_lo found; "
f"got: {[r.getMessage() for r in caplog.records]}"
)
# ── has_token unit tests ─────────────────────────────────────────────────────
def test_has_token_rejects_substrings_inside_words():
assert has_token("israeli parliament dissolved by june 30?", "sol") is False
assert has_token("will canada win group b?", "ada") is False
assert has_token("will seth moulton be the nominee?", "eth") is False
def test_has_token_matches_common_market_formats():
assert has_token("will eth hit $5000?", "eth") is True
assert has_token("$eth above $5000?", "eth") is True
assert has_token("eth/usd above 5000?", "eth") is True
assert has_token("will sol reach $200?", "sol") is True
assert has_token("will ada reach $1?", "ada") is True
assert has_token("BTC to $150k?", "btc") is True # case-insensitive
# ── Regression: false positives must not arm the BTC-dominance signal ───────
def test_israeli_parliament_market_is_not_sol(caplog):
"""'dissolved' contains 'sol' — must NOT flag is_sol/is_altcoin."""
btc_dom_lo = _evaluate_and_parse_btc_dom(
"Israeli parliament dissolved by June 30?", "politics", caplog
)
assert btc_dom_lo == 0.0
def test_canada_market_is_not_ada(caplog):
"""'Canada' contains 'ada' — must NOT flag is_altcoin."""
btc_dom_lo = _evaluate_and_parse_btc_dom(
"Will Canada win Group B?", "events", caplog
)
assert btc_dom_lo == 0.0
def test_seth_moulton_market_is_not_eth(caplog):
"""'Seth' contains 'eth' — must NOT flag is_eth."""
btc_dom_lo = _evaluate_and_parse_btc_dom(
"Will Seth Moulton be the nominee?", "politics", caplog
)
assert btc_dom_lo == 0.0
# ── Real ticker mentions must keep working ───────────────────────────────────
def test_eth_market_detected(caplog):
"""Standalone 'ETH' still flags is_eth: BTC-dom fires and momentum uses ETH."""
btc_dom_lo = _evaluate_and_parse_btc_dom(
"Will ETH hit $5000?", "crypto/finance", caplog
)
assert btc_dom_lo != 0.0
# Momentum picks the ETH branch only when is_eth is True.
full_log = "\n".join(r.getMessage() for r in caplog.records)
assert "ETH 24h: +8.0%" in full_log
def test_dollar_eth_market_detected(caplog):
"""'$ETH' format still flags is_eth."""
btc_dom_lo = _evaluate_and_parse_btc_dom(
"$ETH above $5000?", "crypto/finance", caplog
)
assert btc_dom_lo != 0.0
full_log = "\n".join(r.getMessage() for r in caplog.records)
assert "ETH 24h: +8.0%" in full_log
def test_sol_market_detected(caplog):
"""'SOL reach $200' still flags is_sol → is_altcoin → BTC-dom signal."""
btc_dom_lo = _evaluate_and_parse_btc_dom(
"Will SOL reach $200?", "crypto/finance", caplog
)
# 'reach' → is_price_above, dominance 60 → -0.03 contribution → -0.06 log-odds
assert btc_dom_lo == pytest.approx(-0.06, abs=1e-4)
def test_ada_market_detected(caplog):
"""'ADA reach $1' still flags is_altcoin."""
btc_dom_lo = _evaluate_and_parse_btc_dom(
"Will ADA reach $1?", "crypto/finance", caplog
)
assert btc_dom_lo == pytest.approx(-0.06, abs=1e-4)