4.0 KiB
Signal Staleness Pipeline (v4.4.0, 2026-07-08)
End-to-end flow that auto-filters TG trading signals older than 30 minutes.
Why
Signals arrive via TelegramForwarder in the "交易信号" group. The TG signal source
itself does not embed a timestamp in the message body — it just publishes structured
【币种】【方向】... blocks. Without intervention, process_signal.py treats every
forwarded message as fresh, even if the source posted it hours ago and the price has
since moved 3%.
The chain
[signal source @ TG]
│ posts message at T₀ (real wall-clock time)
▼
[forwarder `is_original_time=1, time_template='⏱信号时间: {time}'`]
│ reads `event.message.date` (UTC, tz-aware)
│ appends "\n\n⏱信号时间: 2026-07-08 17:30:00" to forwarded text
▼
[process_signal.py `parse_signal()`]
│ regex: ⏱信号时间[::]\s*(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})
│ parses → fields['signal_time'] = datetime(...)
▼
[is_signal_stale() in process_signal.py]
│ if (now − signal_time) >= 30 min → STALE
▼
[format_stale_message()] → push to QQ → return early
▲
│ (no advisor call, no execute, no signal_tracker write of consequence)
Configuration touch-points
Forwarder DB (rebuilt from container DB)
import sqlite3
conn = sqlite3.connect('/home/openclaw/TelegramForwarder/db/forward.db')
conn.execute("""
UPDATE forward_rules
SET is_original_time = 1,
time_template = '⏱信号时间: {time}'
WHERE id IN (1, 2)
""")
conn.commit()
After updating, restart the forwarder container:
docker restart telegram-forwarder
Verify the columns exist (they do in Heavrnl/TelegramForwarder's schema):
import sqlite3
conn = sqlite3.connect('/home/openclaw/TelegramForwarder/db/forward.db')
cols = [r[1] for r in conn.execute('PRAGMA table_info(forward_rules)')]
assert 'is_original_time' in cols and 'time_template' in cols
InfoFilter implementation that reads these columns lives at
/app/filters/info_filter.py inside the container.
process_signal.py
SIGNAL_FRESH_MINUTES = 30 # ≥30 算过期(少误跟)
def is_signal_stale(fields):
st = fields.get('signal_time')
if not st:
return False # 无时间戳的旧信号源按新鲜处理
return (datetime.now() - st).total_seconds() >= SIGNAL_FRESH_MINUTES * 60
Edge cases observed
| Case | Behavior |
|---|---|
Signal has no ⏱信号时间 (older source, forwarded differently) |
Treated as fresh — does NOT block. Intentional, so old sources remain routable. |
| Timestamp at exactly 30 min mark | Stale (boundary = ≥, not >). Avoids race on the threshold. |
| Forwarder not yet restarted after DB update | Forwarded messages lack the timestamp → goes through normally. |
| System clock skew between forwarder host and agent | Drift shows up as age_delta. NTP drift is small enough that 30 min is robust. If drift ever causes false STALE → bump SIGNAL_FRESH_MINUTES to 45. |
Rolling back
UPDATE forward_rules SET is_original_time = 0; -- DB rollback
Then in process_signal.py, delete the stale-check branch and the
format_stale_message helper.
Testing without real signals
python3 -c "
from datetime import datetime, timedelta
import sys; sys.path.insert(0, '$HOME/.hermes/skills/trading/okx-auto-position/scripts')
from process_signal import parse_signal, is_signal_stale
for m in [0, 29, 30, 45, 120]:
t = (datetime.now() - timedelta(minutes=m)).strftime('%Y-%m-%d %H:%M:%S')
f = parse_signal(f'【X】⏱信号时间: {t} 【币种】: BTC 【方向】: 做多')
print(f'{m:>3}min → stale={is_signal_stale(f)}')
"
Expected: False False True True True.
Why this is not a cron/script-only concern
The staleness check belongs to process_signal.py itself, NOT to a cron
wrapper, because the agent (/skill_name open-position etc.) can also run
process_signal.py directly via terminal — that path also needs the guard.