fix(strategy): use word-boundary token matching for short crypto tickers to prevent false positives
CI/CD / build-and-push (push) Successful in 8s
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:
co-authored by
Claude Fable 5
parent
4002f03d0c
commit
f5ac302a86
@@ -13,6 +13,7 @@ Polymarket might reflect in a slow-moving order book.
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -159,6 +160,21 @@ def _days_to_resolution(end_date: str) -> int:
|
||||
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
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -345,13 +361,18 @@ class BayesianStrategy:
|
||||
"below", "under", "less than", "lower", "drop",
|
||||
])
|
||||
|
||||
is_btc = "btc" in question_lower or "bitcoin" in question_lower
|
||||
is_eth = "eth" in question_lower or "ethereum" in question_lower
|
||||
is_sol = "sol" in question_lower or "solana" in question_lower
|
||||
is_xrp = "xrp" in question_lower or "ripple" in question_lower
|
||||
is_doge = "doge" in question_lower or "dogecoin" in question_lower
|
||||
# Short tickers need word boundaries: "Seth" contains "eth",
|
||||
# "dissolved" contains "sol", "Canada" contains "ada". Long
|
||||
# unambiguous names (bitcoin, ethereum, …) stay as substrings.
|
||||
is_btc = has_token(question_lower, "btc") or "bitcoin" 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(
|
||||
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(
|
||||
w in question_lower for w in ["crypto", "market cap", "total market", "altcoin", "defi"]
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user