From c2781d2f824f7a99445e4439b6413b5a7e31845a Mon Sep 17 00:00:00 2001 From: Yaniv Bernhard Date: Thu, 17 Sep 2026 21:09:48 +0300 Subject: [PATCH] feat: an open-trades section with a stop-to-target bar; the evaluator ignores the session in progress Section 3, Open trades: every signal bought and not yet resolved, with 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 become section 4 with the tally over resolved trades only, and the instruction in section 1 points a reader who is in to section 3. 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 06:00 UTC build had scored Yahoo's partial row for a session that had not opened. Tests, wiki 01 and 04, README, changelog. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 16 +++ README.md | 2 +- .../wiki/01-Architecture-and-Data-Pipeline.md | 2 +- docs/wiki/04-Testing-and-Contributing.md | 2 +- site/style.css | 9 ++ test_build_site.py | 34 ++++-- test_evaluate.py | 20 ++++ tools/build_site.py | 112 ++++++++++++++---- tools/evaluate_signals.py | 28 ++++- 9 files changed, 193 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac8c99..91aff87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 4da42f6..e22c379 100644 --- a/README.md +++ b/README.md @@ -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 | |---|---|---|---|---| diff --git a/docs/wiki/01-Architecture-and-Data-Pipeline.md b/docs/wiki/01-Architecture-and-Data-Pipeline.md index 2ab2a46..aeab76a 100644 --- a/docs/wiki/01-Architecture-and-Data-Pipeline.md +++ b/docs/wiki/01-Architecture-and-Data-Pipeline.md @@ -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 diff --git a/docs/wiki/04-Testing-and-Contributing.md b/docs/wiki/04-Testing-and-Contributing.md index 24cb83f..3ebebc1 100644 --- a/docs/wiki/04-Testing-and-Contributing.md +++ b/docs/wiki/04-Testing-and-Contributing.md @@ -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. diff --git a/site/style.css b/site/style.css index 4c8d680..de7fbcd 100644 --- a/site/style.css +++ b/site/style.css @@ -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); } diff --git a/test_build_site.py b/test_build_site.py index 42d3b63..2ece7fc 100644 --- a/test_build_site.py +++ b/test_build_site.py @@ -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('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" @@ -154,9 +165,11 @@ def test_render_page_reads_top_to_bottom_and_escapes(): assert page.startswith("") and "How to act. Buy at the next market open" in page assert page.index("How to act.") < page.index('') assert 'href="#why-HAL"' in page and 'id="why-HAL"' in page @@ -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 "1 took profit" in page and "1 stopped out" 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: 1 took profit, 1 stopped out, " \ + "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("") == 4 and open_section.count('HAL" 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 @@ -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 diff --git a/test_evaluate.py b/test_evaluate.py index 8620dfc..0fa33d6 100644 --- a/test_evaluate.py +++ b/test_evaluate.py @@ -3,6 +3,7 @@ from __future__ import annotations +import datetime as dt import json import os import subprocess @@ -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") diff --git a/tools/build_site.py b/tools/build_site.py index d7f526e..eab187a 100644 --- a/tools/build_site.py +++ b/tools/build_site.py @@ -34,10 +34,13 @@ the levels and the pivots, the context (trend, volume, fear and greed), how the pattern has done over eleven years, and the stocks being watched that are not signals yet. -3. **Past signals, were we right?**: every signal the scan has committed, - counted once at its first report and filled at the next open, with the - result in price terms (took profit at, stopped out at, still open at) and - the gain or loss in percent. +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 filled 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. Every number on the page is the scanner's or the evaluator's; the day on the list is the scanner's own count. Nothing here is a new rule or a probability. @@ -261,7 +264,7 @@ def result_text(row: Mapping[str, Any]) -> Tuple[str, str]: def results_view(evaluation: Optional[Mapping[str, Any]]) -> Dict[str, Any]: """Past signals in the order they were reported, each with its result in words, plus the tally.""" if not evaluation: - return {"available": False, "rows": [], "summary": None} + return {"available": False, "rows": [], "open": [], "closed": [], "summary": None} rows = sorted(evaluation.get("rows", []), key=lambda r: (r["last_date"], r["ticker"])) out = [] for r in rows: @@ -269,8 +272,11 @@ def results_view(evaluation: Optional[Mapping[str, Any]]) -> Dict[str, Any]: out.append({"date": r["last_date"], "ticker": r["ticker"], "pattern": r["pattern"], "bought": r.get("fill"), "stop": r["stop"], "target": r.get("target"), "outcome": r["outcome"], "label": label, "text": text, "pnl_pct": r.get("pnl_pct"), "r": r.get("r"), "listed_days": r.get("listed_days"), - "exit": r.get("exit"), "exit_date": r.get("exit_date")}) + "exit": r.get("exit"), "exit_date": r.get("exit_date"), "fill_date": r.get("fill_date"), + "bars": r.get("bars")}) closed = [x for x in out if x["outcome"] in ("target", "stop", "expired") and x["pnl_pct"] is not None] + open_ = [x for x in out if x["label"] in ("open", "pending")] + done = [x for x in out if x["label"] not in ("open", "pending")] summary = {"n": len(out), "wins": sum(1 for x in out if x["label"] == "win"), "losses": sum(1 for x in out if x["label"] == "loss"), "flat": sum(1 for x in out if x["label"] == "flat"), @@ -279,7 +285,7 @@ def results_view(evaluation: Optional[Mapping[str, Any]]) -> Dict[str, Any]: "pending": sum(1 for x in out if x["label"] == "pending"), "avg_pnl_pct": round(sum(x["pnl_pct"] for x in closed) / len(closed), 1) if closed else None, "since": out[0]["date"] if out else None} - return {"available": True, "rows": out, "summary": summary} + return {"available": True, "rows": out, "open": open_, "closed": done, "summary": summary} def track_view(evaluation: Optional[Mapping[str, Any]]) -> Dict[str, Any]: @@ -440,6 +446,31 @@ def x(i: int) -> float: return "".join(parts) +def progress_svg(stop: float, target: Optional[float], bought: float, last: float, width: int = 260) -> str: + """Inline SVG bar from the stop loss (left) to the take profit (right) with the buy price and the last close. + + Without a target the bar runs from the stop to twice the buy price's distance above it. Empty when + the stop is not below the buy price.""" + stop, bought, last = float(stop), float(bought), float(last) + if bought <= stop: + return "" + top = float(target) if target is not None else bought + (bought - stop) + span = top - stop + left, right, h = 8, width - 8, 34 + + def x(v: float) -> float: + return left + max(0.0, min(1.0, (v - stop) / span)) * (right - left) + + return (f'' + f'' + f'' + f'' + f'' + f'{stop:.2f}' + f'{top:.2f}') + + def _levels(v: Mapping[str, Any], entry: Optional[float]) -> Dict[str, Optional[float]]: return {"entry": entry, "max_buy": v.get("max_buy"), "stop": v.get("stop"), "target": v.get("target")} @@ -470,7 +501,8 @@ def _header(vm: Mapping[str, Any]) -> str: return (f'

SignalSync S&P 500 chart-pattern signals

' f'
{_e(meta_line)}
' f'' + f'3. Open trades4. Closed trades' + f'How to read' + (f'

{_e(" ".join(notes))}

' if notes else "") + (f'
{"".join(strip)}
' if strip else "")) @@ -481,7 +513,9 @@ def _glance(vm: Mapping[str, Any]) -> str: bits = [f"{n_now} buy signal{'s' if n_now != 1 else ''}", f"{n_watch} stock{'s' if n_watch != 1 else ''} watched"] if res["available"] and res["summary"]: s = res["summary"] - bits.append(f"past signals: {s['wins']} won, {s['losses']} lost, {s['open']} still open") + n_open = len(res["open"]) + bits.append(f"{n_open} open trade{'s' if n_open != 1 else ''}") + bits.append(f"closed trades: {s['wins']} won, {s['losses']} lost") return f'

Today: {_e(" · ".join(bits))}.

' @@ -512,7 +546,8 @@ def _signals_now(vm: Mapping[str, Any]) -> str: + '
How to act. Buy at the next market open, only if the stock ' 'opens at or below the "buy up to" price. Sell if a day closes at or below the stop loss. Sell at the ' 'take-profit price. A signal stays here while it is still valid and leaves when the stock closes below ' - 'its stop, the pattern fails, or the row is too old to enter.
' + 'its stop, the pattern fails, or the row is too old to enter. Once you are in, follow the trade in ' + 'section 3.' '
' '' f'{rows}
StockPatternBuy up toStop lossTake profitStatus
') @@ -569,17 +604,54 @@ def _why(vm: Mapping[str, Any]) -> str: return head + blocks + _watching(vm) +def _open_row(r: Mapping[str, Any]) -> str: + if r["label"] == "pending": + return (f'{_e(r["ticker"])}{_e(r["pattern"])}' + f'–
at the next open
{_num(r["stop"])}' + f'{_num(r["target"])}––' + f'Pending
{_e(r["text"])}
' + f'0') + bar = (progress_svg(r["stop"], r.get("target"), r["bought"], r["exit"]) + if r.get("bought") is not None and r.get("exit") is not None else "") + return (f'{_e(r["ticker"])}{_e(r["pattern"])}' + f'{_num(r["bought"])}
{_e(r.get("fill_date"))}
' + f'{_num(r["stop"])}{_num(r["target"])}' + f'{_num(r["exit"])}
{_e(r.get("exit_date"))}
' + f'{_signed(r["pnl_pct"], 1, " %")}' + f'{bar}{_e(r.get("bars"))}') + + +def _open_trades(vm: Mapping[str, Any]) -> str: + head = '

3Open trades

' + res = vm["results"] + if not res["available"]: + return head + '

The results are not available in this build.

' + if not res["open"]: + return head + '

No open trade. Every signal so far has been resolved; see section 4.

' + rows = "".join(_open_row(r) for r in res["open"]) + return (head + + '

Every signal, bought at the open after its report, until it is resolved. Sell if a day ' + 'closes at or below the stop loss; take profit at the target. A trade moves to section 4 the morning ' + 'after its low touches the stop, its high touches the target, or 60 sessions pass. The bar shows where ' + 'the last close (blue) stands between the stop loss (red) and the take profit (green); the tick is the ' + 'buy price.

' + '
' + '' + '' + f'{rows}
StockPatternBought atStop lossTake profitLast closeGain / lossWhere it standsDays held
') + + def _results(vm: Mapping[str, Any]) -> str: - head = '

3Past signals: were we right?

' + head = '

4Closed trades: were we right?

' res = vm["results"] if not res["available"]: return head + '

The results are not available in this build.

' s = res["summary"] - tally = (f'{s["n"]} signals since {s["since"]}: {s["wins"]} took profit, ' - f'{s["losses"]} stopped out' - + (f', {s["flat"]} closed flat' if s["flat"] else "") + f', {s["open"]} still open' - + (f', {s["not_bought"]} not bought' if s["not_bought"] else "") - + (f', {s["pending"]} pending' if s.get("pending") else "") + "." + n_closed = len(res["closed"]) + tally = (f'{n_closed} closed trade{"s" if n_closed != 1 else ""} since {s["since"]}: ' + f'{s["wins"]} took profit, {s["losses"]} stopped out' + + (f', {s["flat"]} closed flat' if s["flat"] else "") + + (f', {s["not_bought"]} not bought' if s["not_bought"] else "") + "." + (f' Average result on the closed ones: ' f'{_signed(s["avg_pnl_pct"], 1, " %")}.' if s["avg_pnl_pct"] is not None else "")) rows = "".join( @@ -590,11 +662,11 @@ def _results(vm: Mapping[str, Any]) -> str: f'{_e(RESULT_WORDS[r["label"]])}' f'
{_e(r["text"])}
' f'{_signed(r["pnl_pct"], 1, " %")}' - for r in res["rows"]) + for r in res["closed"]) table = ('
' '' f'{rows}
SignalStockPatternBought atStop lossTake profitResultGain / loss
' - if rows else '

No signal has been logged yet.

') + if rows else '

No trade has been resolved yet.

') ref = vm["reference"]["overall"] honest = (f'

Counted the same way as the eleven-year replay: bought at the next open after the ' f'first report, not bought when that open was above the buy limit or below the stop, take profit and ' @@ -667,8 +739,8 @@ def _footer(vm: Mapping[str, Any]) -> str: def render_body(vm: Mapping[str, Any]) -> str: """The page's body content (inside ``

``).""" - return ('
' + _header(vm) + _glance(vm) + _signals_now(vm) + _why(vm) + _results(vm) - + _how(vm) + _footer(vm) + "
") + return ('
' + _header(vm) + _glance(vm) + _signals_now(vm) + _why(vm) + _open_trades(vm) + + _results(vm) + _how(vm) + _footer(vm) + "
") def _asset(name: str) -> str: diff --git a/tools/evaluate_signals.py b/tools/evaluate_signals.py index 9aded56..ad043d1 100644 --- a/tools/evaluate_signals.py +++ b/tools/evaluate_signals.py @@ -27,6 +27,12 @@ marked to the last close * ``no_data`` -- no bars after ``last_date`` yet +Only completed sessions count: a bar dated on the run's own UTC day is ignored +before 21:00 UTC (the US close), because Yahoo's chart already carries a partial +row for the session in progress, and rows without a full OHLC are dropped. A +signal reported this morning is therefore ``no_data`` until tomorrow's run, +when yesterday's open, the fill, is final. + Gaps fill at the open: a stop at the bar's Open when the Open is already below the stop, a target at the Open when it gaps above. R multiple = (exit - fill) / (fill - stop): a stop is -1 R, the target is @@ -40,6 +46,7 @@ from __future__ import annotations import argparse +import datetime as dt import json import logging import os @@ -188,8 +195,21 @@ def _fetch(ticker: str, start: str) -> pd.DataFrame: return df[["Open", "High", "Low", "Close"]] -def evaluate(signals: Sequence[dict], horizon: int, fetch=_fetch) -> List[dict]: - """Score every signal; ``fetch(ticker, start)`` is injectable for tests. +def session_cutoff(now: Optional[dt.datetime] = None) -> pd.Timestamp: + """The last session whose daily bar is complete at ``now`` (UTC): yesterday before 21:00 UTC, today after. + + The nightly page build runs at about 06:00 UTC, when the US session of the + day has not opened, yet Yahoo's chart may already carry a row for it (from + after-hours trades); scoring that row would buy at a price nobody could + have paid at the open. + """ + now = now or dt.datetime.now(dt.timezone.utc) + day = now.date() if now.hour >= 21 else now.date() - dt.timedelta(days=1) + return pd.Timestamp(day) + + +def evaluate(signals: Sequence[dict], horizon: int, fetch=_fetch, now: Optional[dt.datetime] = None) -> List[dict]: + """Score every signal; ``fetch(ticker, start)`` is injectable for tests, ``now`` (UTC) fixes the session cutoff. :returns: One dict per signal: the reported levels, ``first_seen``, ``listed_days`` / ``last_listed`` (from ``listings``), ``fill`` and @@ -198,6 +218,7 @@ def evaluate(signals: Sequence[dict], horizon: int, fetch=_fetch) -> List[dict]: """ out: List[dict] = [] cache: Dict[str, pd.DataFrame] = {} + cutoff = session_cutoff(now) for s in signals: t = s["ticker"] if t not in cache: @@ -207,6 +228,9 @@ def evaluate(signals: Sequence[dict], horizon: int, fetch=_fetch) -> List[dict]: log.warning("%s: fetch failed: %s", t, exc) cache[t] = pd.DataFrame() bars = cache[t] + if not bars.empty: + bars = bars[bars.index <= cutoff].dropna(subset=[c for c in ("Open", "High", "Low", "Close") + if c in bars.columns]) after = bars[bars.index > pd.Timestamp(s["last_date"])] if not bars.empty else bars max_buy = s.get("max_buy") if max_buy is None: