120 lines
4.0 KiB
Markdown
120 lines
4.0 KiB
Markdown
# 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)
|
||
|
||
```python
|
||
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:
|
||
|
||
```bash
|
||
docker restart telegram-forwarder
|
||
```
|
||
|
||
Verify the columns exist (they do in Heavrnl/TelegramForwarder's schema):
|
||
|
||
```python
|
||
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
|
||
|
||
```python
|
||
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
|
||
|
||
```sql
|
||
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
|
||
|
||
```bash
|
||
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.
|