fix: scorecard counts round trips the broker filled but the journal never closed (TNON)
On 2026-10-02 the live bot bought 7 TNON at 4.62 and the Tradier sell-stop filled at 4.30 forty-nine seconds later, before a position record existed, so no position_closed event was written and the 80-trade scorecard silently dropped a -1R loser. The daily equity-vs-P&L gap ($2.31 that day, cents everywhere else) is what exposed it. load_trades now merges the journal with a cross-check of the broker's filled orders (account_orders_updated snapshots, one row per broker order id). For any session day and symbol with a filled buy and sell but no journaled close, reconstruct_missing synthesises the trade from the fills: fill VWAPs for entry/exit, the sell-stop's stop price for R. Such trades are labelled "[broker fill; journal missed]" and the daily report alerts on a same-day miss. The paper side of the exit A/B skips the check: that store's order snapshots run to 2.2 GB over the window and its Alpaca journal is reconciled by alpaca_paper/sync. Corrected test state: 16/80, mean R -0.08, realized +$0.91 (was 15/80, -0.02, +$3.15). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LXxioW7HA18AY1gqnzA2HN
This commit is contained in:
1 parent
3ea83d568b
commit
b5b38ab798
3 files changed
+128
-5
No files matched your search
@@ -81,6 +81,11 @@ def live_section(con, today, alerts) -> list[str]:
|
||||
if float(eq[0]) - floor < 5:
|
||||
alerts.append(f"Live equity ${float(eq[0]):.2f} is within $5 of the ${floor:.0f} floor.")
|
||||
todays = [t for t in trades if str(t["ts"])[:10] == today]
|
||||
missed_today = [t for t in todays if t.get("source") == "broker"]
|
||||
if missed_today:
|
||||
alerts.append("Journal missed " + ", ".join(t["symbol"] for t in missed_today)
|
||||
+ " today: the broker filled a round trip with no position_closed event "
|
||||
"(counted in the scorecard from the fills).")
|
||||
lines.append(f"- today: {len(todays)} trade(s)" + "".join(
|
||||
f"\n - {t['symbol']} {t['r']:+.2f}R ${t['pnl']:+.2f} ({t['reason']})" if t["r"] is not None else
|
||||
f"\n - {t['symbol']} ${t['pnl']:+.2f} ({t['reason']})" for t in todays))
|
||||
@@ -95,7 +100,7 @@ def live_section(con, today, alerts) -> list[str]:
|
||||
|
||||
|
||||
def ab_section(paper, live, alerts) -> list[str]:
|
||||
P, L = load_trades(paper, AB_SINCE), load_trades(live, AB_SINCE)
|
||||
P, L = load_trades(paper, AB_SINCE, broker_check=False), load_trades(live, AB_SINCE)
|
||||
used, diff = set(), []
|
||||
for x in L:
|
||||
for i, p in enumerate(P):
|
||||
|
||||
@@ -65,9 +65,10 @@ def verdict(r_values: list[float], pnl: float, floor_halts: int,
|
||||
return "FAIL: not profitable after the full sample"
|
||||
|
||||
|
||||
def load_trades(con, since: str) -> list[dict]:
|
||||
"""Closed round trips since ``since`` with R measured against the stop the
|
||||
order was submitted with (position_closed carries the final, trailed stop)."""
|
||||
def journal_trades(con, since: str) -> list[dict]:
|
||||
"""Closed round trips the event store journaled since ``since``, with R measured
|
||||
against the stop the order was submitted with (position_closed carries the
|
||||
final, trailed stop)."""
|
||||
closes = con.execute(
|
||||
"SELECT timestamp, payload_json FROM events WHERE event_type='position_closed' "
|
||||
"AND timestamp >= ? ORDER BY timestamp", [since]).fetchall()
|
||||
@@ -87,10 +88,91 @@ def load_trades(con, since: str) -> list[dict]:
|
||||
if stop0 and entry and exit_p is not None and float(entry) > float(stop0):
|
||||
r = (float(exit_p) - float(entry)) / (float(entry) - float(stop0))
|
||||
out.append({"ts": ts, "symbol": sym, "pnl": float(p.get("realized_pnl") or 0.0),
|
||||
"r": r, "reason": p.get("exit_reason")})
|
||||
"r": r, "reason": p.get("exit_reason"), "source": "journal"})
|
||||
return out
|
||||
|
||||
|
||||
# The broker's order list is polled into account_orders_updated snapshots. Each
|
||||
# row below is one FILLED order, taken from the latest snapshot that showed it
|
||||
# (prices are final there) with the time it was FIRST seen filled as its fill time.
|
||||
_BROKER_FILLS_SQL = """
|
||||
SELECT ts, first_seen, sym, side, typ, fq, fpx, stop_px FROM (
|
||||
SELECT e.timestamp AS ts,
|
||||
min(e.timestamp) OVER (PARTITION BY o->>'broker_order_id') AS first_seen,
|
||||
o->>'symbol' AS sym, o->>'side' AS side, o->>'type' AS typ,
|
||||
(o->>'filled_quantity')::float AS fq, (o->>'filled_avg_price')::float AS fpx,
|
||||
(o->>'stop_price')::float AS stop_px,
|
||||
row_number() OVER (PARTITION BY o->>'broker_order_id' ORDER BY e.timestamp DESC) AS rn
|
||||
FROM events e, jsonb_array_elements(e.payload_json::jsonb->'orders') o
|
||||
WHERE e.event_type='account_orders_updated' AND e.timestamp >= ?
|
||||
AND o->>'status'='filled' AND o->>'type' IS NOT NULL
|
||||
AND (o->>'filled_quantity')::float > 0 AND o->>'filled_avg_price' IS NOT NULL
|
||||
) x WHERE rn = 1 ORDER BY first_seen
|
||||
"""
|
||||
|
||||
|
||||
def broker_fills(con, since: str) -> list[dict]:
|
||||
"""Filled broker orders since ``since`` (one row per broker order id)."""
|
||||
rows = con.execute(_BROKER_FILLS_SQL, [since]).fetchall()
|
||||
return [{"ts": first_seen, "symbol": sym, "side": side, "type": typ, "qty": float(fq),
|
||||
"price": float(fpx), "stop": float(stop_px) if stop_px is not None else None}
|
||||
for _ts, first_seen, sym, side, typ, fq, fpx, stop_px in rows]
|
||||
|
||||
|
||||
def reconstruct_missing(fills: list[dict], journal: list[dict]) -> list[dict]:
|
||||
"""Round trips the broker filled but the journal never closed.
|
||||
|
||||
TNON on 2026-10-02 was bought and stopped out 49 s later; the stop filled
|
||||
before a position record existed, so no position_closed was ever written and
|
||||
the scorecard under-counted the test by one full -1R loser. For every
|
||||
(session date, symbol) with a filled buy AND a filled sell at the broker but
|
||||
no journaled close, this synthesises the trade from the fills: entry/exit are
|
||||
the fill VWAPs, the stop is the sell-stop order's stop price (so R is measured
|
||||
the same way as journaled trades). Pure: no DB, unit-tested.
|
||||
"""
|
||||
journaled = {(str(t["ts"])[:10], t["symbol"]) for t in journal}
|
||||
by_day: dict[tuple[str, str], list[dict]] = {}
|
||||
for f in fills:
|
||||
by_day.setdefault((str(f["ts"])[:10], f["symbol"]), []).append(f)
|
||||
out = []
|
||||
for key, fs in sorted(by_day.items()):
|
||||
if key in journaled:
|
||||
continue
|
||||
buys = [f for f in fs if f["side"] == "buy"]
|
||||
sells = [f for f in fs if f["side"] == "sell"]
|
||||
if not buys or not sells:
|
||||
continue
|
||||
qty = min(sum(f["qty"] for f in buys), sum(f["qty"] for f in sells))
|
||||
if qty <= 0:
|
||||
continue
|
||||
entry = sum(f["qty"] * f["price"] for f in buys) / sum(f["qty"] for f in buys)
|
||||
exit_p = sum(f["qty"] * f["price"] for f in sells) / sum(f["qty"] for f in sells)
|
||||
stops = [f["stop"] for f in sells if f["type"] == "stop" and f["stop"]]
|
||||
stop0 = stops[0] if stops else None
|
||||
r = (exit_p - entry) / (entry - stop0) if stop0 and entry > stop0 else None
|
||||
out.append({"ts": max(f["ts"] for f in sells), "symbol": key[1],
|
||||
"pnl": round(qty * (exit_p - entry), 2), "r": r,
|
||||
"reason": ("stop_loss" if stops else "market_exit") + " [broker fill; journal missed]",
|
||||
"source": "broker"})
|
||||
return out
|
||||
|
||||
|
||||
def load_trades(con, since: str, broker_check: bool = True) -> list[dict]:
|
||||
"""All round trips since ``since``: the journaled ones plus any the broker
|
||||
filled that the journal missed (see reconstruct_missing). ``broker_check``
|
||||
is for the LIVE Tradier store; the paper store's order snapshots run to
|
||||
gigabytes and its Alpaca journal is reconciled by alpaca_paper/sync instead."""
|
||||
journal = journal_trades(con, since)
|
||||
if not broker_check:
|
||||
return journal
|
||||
try:
|
||||
extra = reconstruct_missing(broker_fills(con, since), journal)
|
||||
except Exception as exc: # noqa: BLE001 — the cross-check must never hide the journal
|
||||
print(f"(broker cross-check unavailable: {str(exc)[:120]})")
|
||||
extra = []
|
||||
return sorted(journal + extra, key=lambda t: str(t["ts"]))
|
||||
|
||||
|
||||
def equity_path(con, since: str) -> tuple[float | None, float | None]:
|
||||
rows = con.execute(
|
||||
"SELECT payload_json FROM events WHERE event_type='account_summary_updated' "
|
||||
@@ -135,6 +217,10 @@ def main() -> None:
|
||||
print(f"\nmean R {sum(rs) / len(rs):+.3f} (90% CI {lo:+.3f} .. {hi:+.3f})"
|
||||
f" win rate {wins}/{len(rs)}")
|
||||
print(f"realized P&L ${pnl:+.2f}")
|
||||
missed = [t for t in trades if t.get("source") == "broker"]
|
||||
if missed:
|
||||
print(f"journal missed {len(missed)} round trip(s) the broker filled; counted from the fills: "
|
||||
+ ", ".join(f"{str(t['ts'])[:10]} {t['symbol']}" for t in missed))
|
||||
if eq0 is not None:
|
||||
print(f"equity ${eq0:.2f} -> ${eq1:.2f} (difference from realized P&L = fees + deposits/refunds)")
|
||||
print(f"floor halts: {halts}")
|
||||
|
||||
@@ -28,3 +28,35 @@ def test_freeze_file_is_valid():
|
||||
f = load_freeze()
|
||||
assert f["frozen_on"] == "2026-09-29"
|
||||
assert f["settings"]["TRADING_EXIT_TRAIL_PCT"] == "0.12"
|
||||
|
||||
|
||||
def _fill(ts, sym, side, typ, qty, price, stop=None):
|
||||
return {"ts": ts, "symbol": sym, "side": side, "type": typ, "qty": qty, "price": price, "stop": stop}
|
||||
|
||||
|
||||
def test_reconstruct_missing_counts_the_tnon_style_trade():
|
||||
# 2026-10-02: bought 7 TNON @4.62, sell-stop 4.30 filled 49 s later, no position_closed ever written
|
||||
from research.live_scorecard import reconstruct_missing
|
||||
fills = [_fill("2026-10-02 09:38:40", "TNON", "buy", "limit", 7, 4.62),
|
||||
_fill("2026-10-02 09:39:30", "TNON", "sell", "stop", 7, 4.30, stop=4.30),
|
||||
# a journaled trade the same day must NOT be duplicated
|
||||
_fill("2026-10-02 10:28:10", "CYCU", "buy", "limit", 5, 3.27),
|
||||
_fill("2026-10-02 10:40:05", "CYCU", "sell", "stop", 5, 4.32, stop=4.33)]
|
||||
journal = [{"ts": "2026-10-02 10:40:03", "symbol": "CYCU", "pnl": 5.25, "r": 1.8,
|
||||
"reason": "stop_loss", "source": "journal"}]
|
||||
out = reconstruct_missing(fills, journal)
|
||||
assert [t["symbol"] for t in out] == ["TNON"]
|
||||
t = out[0]
|
||||
assert t["pnl"] == -2.24 and abs(t["r"] + 1.0) < 1e-9
|
||||
assert t["source"] == "broker" and t["reason"].startswith("stop_loss")
|
||||
assert str(t["ts"]).startswith("2026-10-02 09:39")
|
||||
|
||||
|
||||
def test_reconstruct_missing_ignores_one_sided_and_journaled_days():
|
||||
from research.live_scorecard import reconstruct_missing
|
||||
fills = [_fill("2026-10-05 09:36:30", "SBS", "buy", "limit", 5, 6.33), # still open: no sell
|
||||
_fill("2026-10-06 11:10:00", "AIFA", "buy", "limit", 4, 7.425),
|
||||
_fill("2026-10-06 11:10:30", "AIFA", "sell", "market", 4, 7.88)] # journaled below
|
||||
journal = [{"ts": "2026-10-06 11:10:31", "symbol": "AIFA", "pnl": 1.82, "r": 0.6,
|
||||
"reason": "stop_loss", "source": "journal"}]
|
||||
assert reconstruct_missing(fills, journal) == []
|
||||
Reference in new issue
Block a user