Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to this project are documented here. Format follows

## [Unreleased]

### Added (the page: open trades)
- A section of its own for the trades still running: bought at (and when),
stop loss, take profit, last close, gain or loss so far, days held, and a
bar showing where the last close stands between the stop loss and the
take profit with the buy price as a tick. The closed trades keep the
results section, now numbered 4, with the tally over resolved trades
only; the instruction in section 1 points a reader who is in to section 3.

### Fixed (the track record and the session in progress)
- `tools/evaluate_signals.py` ignores bars dated on the run's own UTC day
before 21:00 UTC and rows without a full OHLC: the nightly build at about
06:00 UTC had scored Yahoo's partial row for the session that had not yet
opened, buying MO at a price nobody could have paid at the open. A signal
reported in the morning is now pending until the next run, when its fill,
the previous day's open, is final.

### Changed (the page, rewritten for a first-time reader)
- Three numbered sections in the order a reader needs them: buy signals now
(a table of buy limit, stop loss and take profit, one instruction on how
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

It is a heuristic screener, not trading advice. Every hit should be checked on a chart before acting.

**The page:** [yanivil.github.io/SignalSync](https://yanivil.github.io/SignalSync/), rebuilt after every scan, in three parts: the buy signals now with the buy limit, stop loss and take profit and one instruction on how to act; why each stock was triggered, with its chart; and every past signal with its result in price terms, scored with the backtest's accounting.
**The page:** [yanivil.github.io/SignalSync](https://yanivil.github.io/SignalSync/), rebuilt after every scan, in four parts: the buy signals now with the buy limit, stop loss and take profit and one instruction on how to act; why each stock was triggered, with its chart; the open trades with where each stands between its stop and its target; and every closed trade with its result in price terms, scored with the backtest's accounting.

| Pattern | Type | Confirmation trigger | Stop-loss | Reference target |
|---|---|---|---|---|
Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/01-Architecture-and-Data-Pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ Signals are sorted `CONFIRMED` first, then by score descending. `output/report.m
| `FADED` | the close fell more than `WATCH_PROXIMITY` (5 %) below the entry |
| `DROPPED` | none of the above: the pattern itself no longer qualifies, or no price data. When one of today's reward or patience rules would reject the old row on its own levels and anchors, the detail says so ("reward:risk 0.36 below the minimum 1.5", "no breakout within 60 bars of the right shoulder on 2026-01-29 (156 bars)") |

**Web page.** `tools/build_site.py` renders `output/signals.json`, the live track record (`tools/evaluate_signals.py --json`) and the recent bars (`tools/site_charts.py`) into one self-contained `index.html` (the stylesheet and script in `site/` are inlined) plus `data.json`, the view model, in the order a first-time reader needs: **1. Buy signals now**, the confirmed rows as a table of buy limit, stop loss and take profit with one instruction on how to act and a status (new today, day N of the limit, late), rows leaving when the scanner drops them; **2. Why these stocks**, per row the pattern in plain words, the candlestick chart with the buy zone, stop, target and the pattern's pivots, the context chips (trend, volume, fear and greed) and the eleven-year figure for the pattern, then the stocks being watched (not signals yet, with the close they need) and what left the page since the previous scan; **3. Past signals, were we right?**, every committed signal counted once at its first report and bought at the next open, with the result in price terms (took profit at, stopped out at, still open at, not bought) and the gain or loss in percent, a tally, and the R-based track record folded away; then the glossary and the eleven-year reference tables, folded. Nothing on it is computed anew: every number is the scanner's or the evaluator's, and the day on the list is the scanner's own `listed_day`.
**Web page.** `tools/build_site.py` renders `output/signals.json`, the live track record (`tools/evaluate_signals.py --json`) and the recent bars (`tools/site_charts.py`) into one self-contained `index.html` (the stylesheet and script in `site/` are inlined) plus `data.json`, the view model, in the order a first-time reader needs: **1. Buy signals now**, the confirmed rows as a table of buy limit, stop loss and take profit with one instruction on how to act and a status (new today, day N of the limit, late), rows leaving when the scanner drops them; **2. Why these stocks**, per row the pattern in plain words, the candlestick chart with the buy zone, stop, target and the pattern's pivots, the context chips (trend, volume, fear and greed) and the eleven-year figure for the pattern, then the stocks being watched (not signals yet, with the close they need) and what left the page since the previous scan; **3. Open trades**, every signal bought and not yet resolved, with the last close, the gain or loss so far, the days held and a bar showing where the price stands between the stop loss and the take profit; **4. Closed trades, were we right?**, every resolved signal counted once at its first report and bought at the next open, with the result in price terms (took profit at, stopped out at, not bought) and the gain or loss in percent, a tally, and the R-based track record folded away; then the glossary and the eleven-year reference tables, folded. Nothing on it is computed anew: every number is the scanner's or the evaluator's, and the day on the list is the scanner's own `listed_day`.

## 5. Scheduling

Expand Down
2 changes: 1 addition & 1 deletion docs/wiki/04-Testing-and-Contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,4 @@ gh workflow run backtest.yml -f period=5y -f days=500 -f bars=500 -f split=2025-
gh workflow run backtest.yml -f period=10y -f end=2022-12-30 -f days=250 -f bars=500 -f asof=true -f grid=false -f horizon=60 # 2022 as it was, about 25 minutes
```

`tools/evaluate_signals.py` reads every version of `output/signals.json` from git history (the daily scan commits one per run), keeps the first appearance of each `CONFIRMED` signal keyed on `(ticker, pattern, stop)` together with the sessions on which it was listed, fetches the bars that followed, and scores each one with the backtest's accounting: the fill is the next session's open; an open above the row's Max buy (`gap`) or at or below the stop (`below_stop`) is not traded; then `target` (High reached the target before Low touched the stop), `stop`, `expired` (neither within the horizon, closed at that bar's close) or `open` (still running, marked to the last close), and `no_data` without a bar after the signal. R multiples are `(exit − fill) / (fill − stop)`; each row also carries the fill and exit dates and the profit in percent. `tools/backtest.py` scores its rows through the same `fill_and_classify`, so the live track record and the replay tables are comparable line by line. Run it on GitHub (`gh workflow run evaluate-signals.yml -f horizon=60`) because market-data hosts may be blocked locally.
`tools/evaluate_signals.py` reads every version of `output/signals.json` from git history (the daily scan commits one per run), keeps the first appearance of each `CONFIRMED` signal keyed on `(ticker, pattern, stop)` together with the sessions on which it was listed, fetches the bars that followed, and scores each one with the backtest's accounting: the fill is the next session's open; an open above the row's Max buy (`gap`) or at or below the stop (`below_stop`) is not traded; then `target` (High reached the target before Low touched the stop), `stop`, `expired` (neither within the horizon, closed at that bar's close) or `open` (still running, marked to the last close), and `no_data` without a bar after the signal. R multiples are `(exit − fill) / (fill − stop)`; each row also carries the fill and exit dates and the profit in percent. Only completed sessions count: a bar dated on the run's own UTC day is ignored before 21:00 UTC (Yahoo's chart already carries a partial row for the session in progress) and rows without a full OHLC are dropped, so a signal reported in the morning stays `no_data` until the next run. `tools/backtest.py` scores its rows through the same `fill_and_classify`, so the live track record and the replay tables are comparable line by line. Run it on GitHub (`gh workflow run evaluate-signals.yml -f horizon=60`) because market-data hosts may be blocked locally.
9 changes: 9 additions & 0 deletions site/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -159,3 +159,12 @@ details.how dl.glossary { margin-top: 12px; }
h3#watching { margin-top: 26px; }
.badge-pending { background: var(--accent-bg); color: var(--accent); }
table.results td.date { white-space: nowrap; }
.progress { width: 260px; max-width: 100%; height: auto; display: block; }
.progress .rail { stroke: var(--border); stroke-width: 4; stroke-linecap: round; }
.progress .p-stop { fill: var(--red); }
.progress .p-target { fill: var(--green); }
.progress .p-buy { stroke: var(--muted); stroke-width: 2; }
.progress .p-last { fill: var(--accent); stroke: var(--surface); stroke-width: 2; }
.progress .p-l { fill: var(--muted); font-size: 11px; }
table.open td { white-space: normal; }
table.open td .sub { font-size: 12px; color: var(--muted); }
34 changes: 27 additions & 7 deletions test_build_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,18 @@ def test_results_in_words_and_the_tally():
("WW", "open"), ("CL", "loss"), ("HAL", "open"), ("GAP", "none"), ("WIN", "win"), ("NEW", "pending")]
assert res["summary"] == {"n": 6, "wins": 1, "losses": 1, "flat": 0, "open": 2, "not_bought": 1, "pending": 1,
"avg_pnl_pct": 9.2, "since": "2026-09-01"} # (20.0 - 1.52) / 2 on the closed ones
assert bs.results_view(None) == {"available": False, "rows": [], "summary": None}
assert [r["ticker"] for r in res["open"]] == ["WW", "HAL", "NEW"] # running, or bought at the next open
assert [r["ticker"] for r in res["closed"]] == ["CL", "GAP", "WIN"] # resolved, including not bought
assert res["open"][1]["fill_date"] == "2026-09-02" and res["open"][1]["bars"] == 3
assert bs.results_view(None) == {"available": False, "rows": [], "open": [], "closed": [], "summary": None}
# The bar: stop at the left end, target at the right, the last close and the buy price in between.
bar = bs.progress_svg(66.0, 75.9, 70.1, 70.6)
assert bar.startswith('<svg class="progress"') and ">66.00<" in bar and ">75.90<" in bar
rail = lambda v: f"{8 + (v - 66.0) / 9.9 * 244:.1f}" # noqa: E731 position on the 244 px rail
assert f'class="p-last" cx="{rail(70.6)}"' in bar and f'class="p-buy" x1="{rail(70.1)}"' in bar
assert 'cx="252.0"' in bs.progress_svg(66.0, 75.9, 70.1, 99.0) # clamped at the target end
assert ">74.20<" in bs.progress_svg(66.0, None, 70.1, 71.0) # no target: stop to twice the risk
assert bs.progress_svg(66.0, 75.9, 60.0, 61.0) == "" # buy price below the stop: nothing
t = bs.track_view(_evaluation())
assert t["curve"] == [0.1, -0.9, -1.06, 0.94] and t["rows"][1]["label"] == "Stopped out at 88.67 on 2026-09-02"

Expand All @@ -154,9 +165,11 @@ def test_render_page_reads_top_to_bottom_and_escapes():
assert page.startswith("<!doctype html>") and "<style>" in page and "<script>" in page
assert "Cup &amp; Handle" in page and "Inverse Head &amp; Shoulders" in page and "S&amp;P 500" in page
# The three sections in order, the instruction before the first table.
now, why, results, how = (page.index(x) for x in ('id="now"', 'id="why"', 'id="results"', 'id="how"'))
assert now < why < results < how
assert "Today: 3 buy signals · 1 stock watched · past signals: 1 won, 1 lost, 2 still open." in page
now, why, open_, results, how = (page.index(x) for x in ('id="now"', 'id="why"', 'id="open"', 'id="results"',
'id="how"'))
assert now < why < open_ < results < how
assert "Today: 3 buy signals · 1 stock watched · 3 open trades · closed trades: 1 won, 1 lost." in page
assert 'follow the trade in <a href="#open">section 3</a>' in page
assert "<strong>How to act.</strong> Buy at the next market open" in page
assert page.index("How to act.") < page.index('<table class="signals">')
assert 'href="#why-HAL"' in page and 'id="why-HAL"' in page
Expand All @@ -167,8 +180,14 @@ def test_render_page_reads_top_to_bottom_and_escapes():
assert "Bull trend" in page and "VIX 15.7" in page and "63% of stocks above their 200-day average" in page
assert "Needs a close above" in page
assert "Left the page since the previous scan: FIS (closed below its stop)." in page
assert "<strong>1 took profit</strong>" in page and "<strong>1 stopped out</strong>" in page
assert "2 still open, 1 not bought, 1 pending." in page and 'class="badge badge-pending">Pending' in page
assert "3 closed trades since 2026-09-01: <strong>1 took profit</strong>, <strong>1 stopped out</strong>, " \
"1 not bought." in page
assert 'class="badge badge-pending">Pending' in page and "Still open: last close 36.80 on 2026-09-02" not in page
# Open trades: the running ones with the bar, the pending one without.
open_section = page[page.index('id="open"'):page.index('id="results"')]
assert open_section.count("<tr>") == 4 and open_section.count('<svg class="progress"') == 2
assert "<strong>HAL</strong>" in open_section and "at the next open" in open_section
assert "Where it stands" in open_section and "Days held" in open_section
assert "The breakout is the last session; the row is dropped after 8." in page
assert "Stopped out at 88.67 on 2026-09-02" in page and "Took profit at 120.00 on 2026-09-03" in page
assert "Not bought: it opened at 108.00, above the buy limit" in page
Expand All @@ -194,7 +213,8 @@ def test_build_writes_files_and_handles_an_empty_scan(tmp_path):
page = (out / "index.html").read_text()
assert vm["scan"]["stale_days"] == 12 and "12 days old" in page
assert "No buy signal today. Nothing to do." in page and "Nothing is being watched right now." in page
assert "The results are not available in this build" in page and "Today: 0 buy signals · 0 stocks watched." in page
assert page.count("The results are not available in this build") == 2
assert "Today: 0 buy signals · 0 stocks watched." in page
assert bs.main(["--signals", str(sig), "--out-dir", str(out), "--today", "2026-09-09"]) == 0


Expand Down
20 changes: 20 additions & 0 deletions test_evaluate.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

from __future__ import annotations

import datetime as dt
import json
import os
import subprocess
Expand Down Expand Up @@ -124,6 +125,25 @@ def fetch(ticker, start):

rows = ev.evaluate(hist, horizon=60, fetch=fetch)
assert calls == [("AAA", "2026-03-02"), ("BBB", "2026-03-03")]
# Only completed sessions count: before 21:00 UTC the run's own day is out, so a signal reported this
# morning is no_data until the next run, and a row without a full bar is dropped.
early = dt.datetime(2026, 3, 3, 6, 0, tzinfo=dt.timezone.utc) # nothing after 2026-03-02 yet
assert ev.evaluate(hist, horizon=60, fetch=fetch, now=early)[0]["outcome"] == "no_data"
later = dt.datetime(2026, 3, 4, 6, 0, tzinfo=dt.timezone.utc) # 03-03 complete, 03-04 not yet
(aaa_early, _) = ev.evaluate(hist, horizon=60, fetch=fetch, now=later)
assert (aaa_early["outcome"], aaa_early["bars"], aaa_early["exit_date"]) == ("open", 1, "2026-03-03")
evening = dt.datetime(2026, 3, 4, 21, 0, tzinfo=dt.timezone.utc) # after the close: 03-04 counts
assert ev.evaluate(hist, horizon=60, fetch=fetch, now=evening)[0]["outcome"] == "target"
assert ev.session_cutoff(early) == pd.Timestamp("2026-03-02")
assert ev.session_cutoff(evening) == pd.Timestamp("2026-03-04")

def fetch_nan(ticker, start):
df = fetch(ticker, start)
df.loc[df.index[1], "Close"] = float("nan") # 03-03 half-written at Yahoo
return df

(aaa_nan, _) = ev.evaluate(hist, horizon=60, fetch=fetch_nan, now=later)
assert aaa_nan["outcome"] == "no_data" # the only complete row is gone
aaa, bbb = rows
assert aaa["outcome"] == "target" and aaa["bars"] == 2 # the signal-day bar (Low 50) is excluded
assert (aaa["fill"], aaa["fill_date"], aaa["exit"], aaa["exit_date"]) == (100.0, "2026-03-03", 120.0, "2026-03-04")
Expand Down
Loading
Loading