Claude (oner) Claude Opus 4.7 commited on
Commit
ba3d3a6
Β·
1 Parent(s): a9b8649

fix: scan overlap protection + snapshot routing + worker history tables

Browse files

Four fixes to keep the dashboard and worker honest while running real money:

A) Snapshot routing fix (worker.py:_sync_to_supabase)
Top-level fields (status/portfolio/closedTrades/performance/eligibleStocks)
were being copied from markets[_CURRENT_MARKET_ID], which flipped
between BIST and US depending on which market the worker happened to
process last. Result: a BIST winning trade vanished from top-level
while the US tab "inherited" it; performance showed winRate=0 even
though IZENR returned +12.93%.

Pin the top-level view to a canonical primary market (BIST by default,
override via TOPLEVEL_MARKET_ID env var). Per-market detail is still
under markets.{id}. Add `topLevelMarketId` + keep `activeMarketId` for
back-compat, and surface `currentlyProcessingMarketId` for diagnostics.

C) Overlap protection (worker.py:_latest_scan_payload_for_market,
app.py:_load_scan_payload, app.py:/api/eligible)
When a fresh chunked scan starts, the work file (eligible=2, stage2=9)
has a newer timestamp than the previous completed final file
(eligible=15, stage2=95). Old sort key was timestamp-only, so the
in-progress scan would shadow the completed one for hours until the
new run finished β€” risking the worker trading on a partial eligible
list at the next market open.

Sort key now ranks `completed=True` above `completed=False` first,
timestamp only as tiebreaker. Staleness of completed scans is still
enforced separately via SCAN_MAX_AGE_HOURS so we don't pin to ancient
data.

B) Boot-scan staleness threshold raised 72h β†’ 168h (7 days), driven by
BOOT_SCAN_MAX_AGE_HOURS env var. The 72h figure was the root cause of
the 17β†’20 May "scan gap" β€” nightly scans were getting skipped because
the previous completion was still within 72h, but the container had
been asleep during the actual 19:00 TR window. Nightly re-runs are
still enforced separately via SCAN_MAX_AGE_HOURS (default 20h) so
freshness isn't sacrificed.

D) Worker history tables (supabase/migrations/20260521_worker_history.sql
+ worker.py:_sync_worker_history_to_db)
snapshot.json is overwritten on every sync β€” fine for "what's
happening now", useless for "show me every trade in the last 3
months". Three new public-read tables (worker_trades, worker_signals,
worker_equity) give us durable, queryable per-row history. UPSERT via
PostgREST `Prefer: resolution=merge-duplicates` keyed on UNIQUE
indexes (market_id+source_id / market_id+date+symbol / market_id+date)
so re-uploads are idempotent. Service-role writes only; anon SELECT.

The DB sync is best-effort: failures log a warning but never block the
snapshot.json upload that the live dashboard depends on.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

app.py CHANGED
@@ -339,8 +339,14 @@ def _daily_scan_scheduler():
339
  if finished:
340
  scan_dt = datetime.fromisoformat(str(finished).replace("Z", "+00:00"))
341
  age_hours = (datetime.now(timezone(timedelta(hours=0))) - scan_dt).total_seconds() / 3600
342
- if age_hours > 72:
343
- print(f"[scheduler] {market_id} scan results stale ({age_hours:.0f}h old)")
 
 
 
 
 
 
344
  try:
345
  from trading.telegram_notify import notify as _tgn
346
  _tgn("scan_stale", market_id=market_id, age_hours=age_hours)
@@ -348,7 +354,7 @@ def _daily_scan_scheduler():
348
  pass
349
  return True
350
  else:
351
- print(f"[scheduler] {market_id} scan results fresh ({age_hours:.0f}h old)")
352
  return False
353
  else:
354
  return True
@@ -465,6 +471,19 @@ def _scan_stage_counts(data: dict) -> dict:
465
 
466
 
467
  def _load_scan_payload(market_id: str) -> tuple[Optional[dict], str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  from trading.market_registry import get_scan_results_path
469
 
470
  work_file = get_scan_results_path(market_id, completed=False)
@@ -483,12 +502,16 @@ def _load_scan_payload(market_id: str) -> tuple[Optional[dict], str]:
483
  return None, "none"
484
  loaded.sort(
485
  key=lambda item: (
 
 
 
486
  max(
487
  _scan_timestamp(item[1].get("updated_at")),
488
  _scan_timestamp(item[1].get("scan_finished")),
489
  _scan_timestamp(item[1].get("chunk_paused_at")),
490
  _scan_timestamp(item[1].get("scan_started")),
491
  ),
 
492
  1 if item[0] == "final" else 0,
493
  ),
494
  reverse=True,
@@ -2436,7 +2459,20 @@ def get_eligible_stocks(market: str = Query("bist")):
2436
  _timestamp_of((work_data or {}).get("scan_started")),
2437
  )
2438
 
2439
- using_work_file = bool(work_data) and (not final_data or work_ts >= final_ts)
 
 
 
 
 
 
 
 
 
 
 
 
 
2440
  data = work_data if using_work_file else final_data
2441
  if data is None:
2442
  raise RuntimeError("Scan payload is empty")
 
339
  if finished:
340
  scan_dt = datetime.fromisoformat(str(finished).replace("Z", "+00:00"))
341
  age_hours = (datetime.now(timezone(timedelta(hours=0))) - scan_dt).total_seconds() / 3600
342
+ # Boot-scan staleness threshold: previously 72h (3 days),
343
+ # which combined with HF free-tier sleep meant scans would
344
+ # be skipped during normal nightly windows. Now driven by
345
+ # BOOT_SCAN_MAX_AGE_HOURS (default 168h = 7 days). Nightly
346
+ # re-runs are still enforced separately via SCAN_MAX_AGE_HOURS.
347
+ boot_max_age = float(os.environ.get("BOOT_SCAN_MAX_AGE_HOURS", "168"))
348
+ if age_hours > boot_max_age:
349
+ print(f"[scheduler] {market_id} scan results stale ({age_hours:.0f}h > {boot_max_age:.0f}h)")
350
  try:
351
  from trading.telegram_notify import notify as _tgn
352
  _tgn("scan_stale", market_id=market_id, age_hours=age_hours)
 
354
  pass
355
  return True
356
  else:
357
+ print(f"[scheduler] {market_id} scan results fresh ({age_hours:.0f}h old, <{boot_max_age:.0f}h)")
358
  return False
359
  else:
360
  return True
 
471
 
472
 
473
  def _load_scan_payload(market_id: str) -> tuple[Optional[dict], str]:
474
+ """Pick the best scan payload to expose to consumers.
475
+
476
+ Overlap protection: a COMPLETED scan is preferred over an in-progress (work)
477
+ scan even when the work scan has a newer timestamp. Without this, a freshly
478
+ started chunked scan with only a handful of stage2 results would shadow the
479
+ previous fully-completed scan (e.g. eligible=2 instead of eligible=15)
480
+ until it finishes hours/days later.
481
+
482
+ Tiebreaker: when both candidates share the same completed flag, the newer
483
+ timestamp wins. A staleness check is applied separately in
484
+ `_scan_already_completed` (SCAN_MAX_AGE_HOURS) so we still re-run scans
485
+ whose completed snapshot has aged out.
486
+ """
487
  from trading.market_registry import get_scan_results_path
488
 
489
  work_file = get_scan_results_path(market_id, completed=False)
 
502
  return None, "none"
503
  loaded.sort(
504
  key=lambda item: (
505
+ # 1) completed scans rank above in-progress scans
506
+ bool(item[1].get("completed")),
507
+ # 2) tiebreak by max known timestamp
508
  max(
509
  _scan_timestamp(item[1].get("updated_at")),
510
  _scan_timestamp(item[1].get("scan_finished")),
511
  _scan_timestamp(item[1].get("chunk_paused_at")),
512
  _scan_timestamp(item[1].get("scan_started")),
513
  ),
514
+ # 3) final source-label beats work-label when everything else ties
515
  1 if item[0] == "final" else 0,
516
  ),
517
  reverse=True,
 
2459
  _timestamp_of((work_data or {}).get("scan_started")),
2460
  )
2461
 
2462
+ # Overlap protection: prefer a completed (final) scan over an
2463
+ # in-progress (work) scan, even if the work scan is newer. Falls back
2464
+ # to the work scan only when no completed scan exists, or when the
2465
+ # work scan itself is already completed and newer.
2466
+ final_completed = bool((final_data or {}).get("completed"))
2467
+ work_completed = bool((work_data or {}).get("completed"))
2468
+ if work_data and (
2469
+ not final_data
2470
+ or (work_completed and (work_ts >= final_ts or not final_completed))
2471
+ or (not final_completed and not work_completed and work_ts >= final_ts)
2472
+ ):
2473
+ using_work_file = True
2474
+ else:
2475
+ using_work_file = False
2476
  data = work_data if using_work_file else final_data
2477
  if data is None:
2478
  raise RuntimeError("Scan payload is empty")
supabase/migrations/20260521_worker_history.sql ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- =====================================================================
2
+ -- Worker history tables: durable per-row storage of every automated
3
+ -- trade, signal, and equity point produced by the trading worker.
4
+ --
5
+ -- Why this exists:
6
+ -- The dashboard already gets a denormalised snapshot from
7
+ -- storage/v1/object/public/trading/snapshot.json. That blob is
8
+ -- overwritten on every sync β€” fine for "what's happening now", useless
9
+ -- for "show me every trade in the last 3 months" or "average win rate
10
+ -- by symbol". These tables give us durable, queryable history.
11
+ --
12
+ -- Visibility:
13
+ -- PUBLIC READ (anon SELECT), WRITE only via service_role. The dashboard
14
+ -- can pull historical analytics without auth; the worker writes with
15
+ -- its SUPABASE_SERVICE_ROLE_KEY.
16
+ --
17
+ -- Idempotency:
18
+ -- - worker_trades: UNIQUE(market_id, source_id) β€” `source_id` is the
19
+ -- row id from the worker's SQLite `trades` table, so re-uploading the
20
+ -- same trade is a no-op via ON CONFLICT.
21
+ -- - worker_signals: UNIQUE(market_id, date, symbol) β€” one signal per
22
+ -- symbol per day.
23
+ -- - worker_equity: UNIQUE(market_id, date) β€” one equity point per day.
24
+ -- =====================================================================
25
+
26
+ CREATE TABLE IF NOT EXISTS worker_trades (
27
+ id BIGSERIAL PRIMARY KEY,
28
+ market_id TEXT NOT NULL,
29
+ source_id INTEGER NOT NULL,
30
+ symbol TEXT NOT NULL,
31
+ side TEXT NOT NULL CHECK (side IN ('BUY', 'SELL')),
32
+ entry_date DATE,
33
+ entry_price NUMERIC(14, 4),
34
+ exit_date DATE,
35
+ exit_price NUMERIC(14, 4),
36
+ quantity INTEGER NOT NULL,
37
+ entry_commission NUMERIC(12, 4),
38
+ exit_commission NUMERIC(12, 4),
39
+ gross_pnl NUMERIC(14, 4),
40
+ net_pnl NUMERIC(14, 4),
41
+ return_pct NUMERIC(10, 4),
42
+ holding_days INTEGER,
43
+ signal_confidence NUMERIC(10, 4),
44
+ predicted_return NUMERIC(10, 4),
45
+ entry_reason TEXT,
46
+ exit_reason TEXT,
47
+ is_closed BOOLEAN NOT NULL DEFAULT FALSE,
48
+ created_at TIMESTAMPTZ DEFAULT NOW(),
49
+ updated_at TIMESTAMPTZ DEFAULT NOW()
50
+ );
51
+
52
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_trades_dedup
53
+ ON worker_trades(market_id, source_id);
54
+ CREATE INDEX IF NOT EXISTS idx_worker_trades_market_entry
55
+ ON worker_trades(market_id, entry_date DESC);
56
+ CREATE INDEX IF NOT EXISTS idx_worker_trades_symbol
57
+ ON worker_trades(symbol);
58
+ CREATE INDEX IF NOT EXISTS idx_worker_trades_closed
59
+ ON worker_trades(market_id, is_closed, exit_date DESC);
60
+
61
+ COMMENT ON TABLE worker_trades IS
62
+ 'Per-row durable record of every automated worker trade (paper or live). '
63
+ 'Snapshot.json is denormalised; this table is the source of truth for '
64
+ 'historical analytics (win rate by symbol/date, PnL distribution, etc.).';
65
+
66
+ -- ---------------------------------------------------------------------
67
+
68
+ CREATE TABLE IF NOT EXISTS worker_signals (
69
+ id BIGSERIAL PRIMARY KEY,
70
+ market_id TEXT NOT NULL,
71
+ date DATE NOT NULL,
72
+ symbol TEXT NOT NULL,
73
+ signal TEXT NOT NULL,
74
+ confidence NUMERIC(10, 4),
75
+ predicted_return NUMERIC(10, 4),
76
+ ml_signal TEXT,
77
+ tech_signal TEXT,
78
+ action TEXT,
79
+ created_at TIMESTAMPTZ DEFAULT NOW()
80
+ );
81
+
82
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_signals_dedup
83
+ ON worker_signals(market_id, date, symbol);
84
+ CREATE INDEX IF NOT EXISTS idx_worker_signals_market_date
85
+ ON worker_signals(market_id, date DESC);
86
+
87
+ COMMENT ON TABLE worker_signals IS
88
+ 'Per-day per-symbol signal output. One row per (market, date, symbol). '
89
+ 'Used to compare ML predictions against realised outcomes over time.';
90
+
91
+ -- ---------------------------------------------------------------------
92
+
93
+ CREATE TABLE IF NOT EXISTS worker_equity (
94
+ id BIGSERIAL PRIMARY KEY,
95
+ market_id TEXT NOT NULL,
96
+ date DATE NOT NULL,
97
+ equity NUMERIC(14, 4) NOT NULL,
98
+ cash NUMERIC(14, 4) NOT NULL,
99
+ positions_count INTEGER NOT NULL DEFAULT 0,
100
+ unrealized_pnl NUMERIC(14, 4),
101
+ realized_pnl_today NUMERIC(14, 4),
102
+ created_at TIMESTAMPTZ DEFAULT NOW(),
103
+ updated_at TIMESTAMPTZ DEFAULT NOW()
104
+ );
105
+
106
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_equity_dedup
107
+ ON worker_equity(market_id, date);
108
+ CREATE INDEX IF NOT EXISTS idx_worker_equity_market_date
109
+ ON worker_equity(market_id, date DESC);
110
+
111
+ COMMENT ON TABLE worker_equity IS
112
+ 'Daily equity-curve point per market. Append-only on new days, upsert '
113
+ 'on intraday rewrites.';
114
+
115
+ -- =====================================================================
116
+ -- RLS: anonymous read, service-role write.
117
+ -- =====================================================================
118
+
119
+ ALTER TABLE worker_trades ENABLE ROW LEVEL SECURITY;
120
+ ALTER TABLE worker_signals ENABLE ROW LEVEL SECURITY;
121
+ ALTER TABLE worker_equity ENABLE ROW LEVEL SECURITY;
122
+
123
+ DROP POLICY IF EXISTS worker_trades_anon_read ON worker_trades;
124
+ DROP POLICY IF EXISTS worker_signals_anon_read ON worker_signals;
125
+ DROP POLICY IF EXISTS worker_equity_anon_read ON worker_equity;
126
+
127
+ CREATE POLICY worker_trades_anon_read
128
+ ON worker_trades FOR SELECT TO anon, authenticated
129
+ USING (TRUE);
130
+
131
+ CREATE POLICY worker_signals_anon_read
132
+ ON worker_signals FOR SELECT TO anon, authenticated
133
+ USING (TRUE);
134
+
135
+ CREATE POLICY worker_equity_anon_read
136
+ ON worker_equity FOR SELECT TO anon, authenticated
137
+ USING (TRUE);
138
+
139
+ -- Service-role bypasses RLS automatically; no explicit insert/update
140
+ -- policies needed for the worker. Anon/authenticated cannot mutate.
trading/worker.py CHANGED
@@ -133,6 +133,17 @@ def _scan_payload_timestamp(data: Optional[Dict[str, Any]]) -> float:
133
 
134
 
135
  def _latest_scan_payload_for_market(market_id: str) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
136
  work_path = get_scan_results_path(market_id, completed=False)
137
  final_path = get_scan_results_path(market_id, completed=True)
138
 
@@ -155,7 +166,12 @@ def _latest_scan_payload_for_market(market_id: str) -> Dict[str, Any]:
155
  if not candidates:
156
  return {}
157
 
158
- candidates.sort(key=_scan_payload_timestamp, reverse=True)
 
 
 
 
 
159
  return candidates[0]
160
 
161
 
@@ -469,10 +485,27 @@ def _sync_to_supabase() -> None:
469
  import urllib.error
470
 
471
  markets = {market_id: _build_market_snapshot(market_id) for market_id in _SUPPORTED_MARKET_IDS}
472
- current_market = markets[_CURRENT_MARKET_ID]
473
- state_data = _load_json_file(_state_file())
474
- status_data = _load_json_file(_status_file())
475
- scan_results = _load_json_file(_scan_results_file())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  open_trades = current_market["openTrades"]
477
  closed_trades = current_market["closedTrades"]
478
  trades = open_trades + closed_trades
@@ -496,7 +529,7 @@ def _sync_to_supabase() -> None:
496
 
497
  # Read kill switch status from disk (file-based, survives restarts)
498
  from trading.monitoring import KillSwitch
499
- _ks_path = get_market_storage_dir(_CURRENT_MARKET_ID) / ".kill_switch"
500
  _ks = KillSwitch(path=_ks_path)
501
 
502
  # Use actual initial_cash from state, not hardcoded 100K
@@ -558,7 +591,14 @@ def _sync_to_supabase() -> None:
558
  },
559
  "eligibleStocks": current_market["eligibleStocks"],
560
  "markets": markets,
561
- "activeMarketId": _CURRENT_MARKET_ID,
 
 
 
 
 
 
 
562
  "_syncedAt": datetime.now(timezone.utc).isoformat(),
563
  }
564
 
@@ -583,12 +623,134 @@ def _sync_to_supabase() -> None:
583
  cash, len(positions), len(trades),
584
  )
585
 
 
 
 
 
 
 
 
 
 
 
586
  except urllib.error.HTTPError as e:
587
  logger.warning("Supabase sync HTTP %s: %s", e.code, e.read().decode()[:200])
588
  except Exception as e:
589
  logger.warning("Supabase sync failed: %s", e)
590
 
591
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  class TradingWorker:
593
  """Production trading worker with full safety stack.
594
 
 
133
 
134
 
135
  def _latest_scan_payload_for_market(market_id: str) -> Dict[str, Any]:
136
+ """Return the best scan payload for downstream consumers.
137
+
138
+ Selection policy (overlap protection): a COMPLETED scan is always preferred
139
+ over an in-progress (work) scan, even if the work scan has a newer
140
+ timestamp. This prevents the dashboard/worker from seeing a partial
141
+ eligible list (e.g. eligible=2 of 95) while a new chunked scan is mid-flight
142
+ and overwriting the prior eligible list (e.g. eligible=15).
143
+
144
+ Tiebreaker: if both candidates are completed (or both in-progress), pick the
145
+ one with the newer timestamp.
146
+ """
147
  work_path = get_scan_results_path(market_id, completed=False)
148
  final_path = get_scan_results_path(market_id, completed=True)
149
 
 
166
  if not candidates:
167
  return {}
168
 
169
+ # Sort: (completed=True ranks first, then newer timestamp wins as tiebreak).
170
+ # reverse=True so True > False and larger timestamp > smaller.
171
+ candidates.sort(
172
+ key=lambda d: (bool(d.get("completed")), _scan_payload_timestamp(d)),
173
+ reverse=True,
174
+ )
175
  return candidates[0]
176
 
177
 
 
485
  import urllib.error
486
 
487
  markets = {market_id: _build_market_snapshot(market_id) for market_id in _SUPPORTED_MARKET_IDS}
488
+
489
+ # ── Snapshot routing fix ──────────────────────────────────────────
490
+ # Previously the top-level fields (status/portfolio/closedTrades/
491
+ # performance/eligibleStocks) were copied from `markets[_CURRENT_MARKET_ID]`,
492
+ # which changed depending on which market the worker happened to be
493
+ # processing last. The result was a confusing dashboard where switching
494
+ # the active market silently swapped what "the headline numbers" meant
495
+ # β€” e.g. a BIST winning trade vanished from top-level while the US
496
+ # tab inherited it.
497
+ #
498
+ # We now pin the top-level view to a canonical primary market (BIST by
499
+ # default, overridable via TOPLEVEL_MARKET_ID env var). Per-market
500
+ # detail is still available under `markets.{id}`.
501
+ topl_id = os.environ.get("TOPLEVEL_MARKET_ID", "bist").strip().lower()
502
+ if topl_id not in markets:
503
+ topl_id = _CURRENT_MARKET_ID if _CURRENT_MARKET_ID in markets else next(iter(markets))
504
+ current_market = markets[topl_id]
505
+
506
+ state_data = _load_json_file(_state_file_for_market(topl_id))
507
+ status_data = _load_json_file(_status_file_for_market(topl_id))
508
+ scan_results = _load_json_file(_scan_results_file_for_market(topl_id))
509
  open_trades = current_market["openTrades"]
510
  closed_trades = current_market["closedTrades"]
511
  trades = open_trades + closed_trades
 
529
 
530
  # Read kill switch status from disk (file-based, survives restarts)
531
  from trading.monitoring import KillSwitch
532
+ _ks_path = get_market_storage_dir(topl_id) / ".kill_switch"
533
  _ks = KillSwitch(path=_ks_path)
534
 
535
  # Use actual initial_cash from state, not hardcoded 100K
 
591
  },
592
  "eligibleStocks": current_market["eligibleStocks"],
593
  "markets": markets,
594
+ # Pinned market for top-level fields (see snapshot routing fix above).
595
+ "topLevelMarketId": topl_id,
596
+ # Backwards-compat: keep the older `activeMarketId` field so any
597
+ # existing frontend reading it gets the same value.
598
+ "activeMarketId": topl_id,
599
+ # The market the worker is currently processing (for diagnostics
600
+ # only β€” top-level fields are NOT tied to this anymore).
601
+ "currentlyProcessingMarketId": _CURRENT_MARKET_ID,
602
  "_syncedAt": datetime.now(timezone.utc).isoformat(),
603
  }
604
 
 
623
  cash, len(positions), len(trades),
624
  )
625
 
626
+ # ── Per-row history sync to Supabase DB tables ────────────────────
627
+ # snapshot.json above is fast/denormalised for the live dashboard.
628
+ # These rows give us durable, queryable history for analytics
629
+ # (win-rate by symbol, PnL distribution, ML calibration over time).
630
+ # Idempotent via UNIQUE indexes (see 20260521_worker_history.sql).
631
+ try:
632
+ _sync_worker_history_to_db(markets)
633
+ except Exception as exc:
634
+ logger.warning("Worker history DB sync failed: %s", exc)
635
+
636
  except urllib.error.HTTPError as e:
637
  logger.warning("Supabase sync HTTP %s: %s", e.code, e.read().decode()[:200])
638
  except Exception as e:
639
  logger.warning("Supabase sync failed: %s", e)
640
 
641
 
642
+ def _sync_worker_history_to_db(markets: Dict[str, Dict[str, Any]]) -> None:
643
+ """Upsert per-row trade/signal/equity history to Supabase Postgres.
644
+
645
+ Uses PostgREST `Prefer: resolution=merge-duplicates` so re-uploading the
646
+ same row is a no-op via the UNIQUE indexes defined in migration
647
+ 20260521_worker_history.sql. Errors are logged but never raised β€” DB
648
+ sync is best-effort and must not block the dashboard snapshot upload.
649
+ """
650
+ if not _SUPABASE_SERVICE_KEY:
651
+ return
652
+
653
+ import urllib.request
654
+ import urllib.error
655
+
656
+ def _post_rows(table: str, rows: List[Dict[str, Any]], on_conflict: str) -> None:
657
+ if not rows:
658
+ return
659
+ url = (
660
+ f"{_SUPABASE_URL}/rest/v1/{table}"
661
+ f"?on_conflict={on_conflict}"
662
+ )
663
+ req = urllib.request.Request(
664
+ url,
665
+ data=json.dumps(rows, default=str).encode("utf-8"),
666
+ headers={
667
+ "Content-Type": "application/json",
668
+ "Authorization": f"Bearer {_SUPABASE_SERVICE_KEY}",
669
+ "apikey": _SUPABASE_SERVICE_KEY,
670
+ # merge-duplicates β†’ UPSERT; return=minimal β†’ no body needed
671
+ "Prefer": "resolution=merge-duplicates,return=minimal",
672
+ },
673
+ method="POST",
674
+ )
675
+ try:
676
+ with urllib.request.urlopen(req, timeout=30) as resp:
677
+ resp.read()
678
+ logger.info("DB sync %s: %d row(s) upserted", table, len(rows))
679
+ except urllib.error.HTTPError as e:
680
+ logger.warning("DB sync %s HTTP %s: %s", table, e.code, e.read().decode()[:200])
681
+ except Exception as e:
682
+ logger.warning("DB sync %s failed: %s", table, e)
683
+
684
+ trades_rows: List[Dict[str, Any]] = []
685
+ signals_rows: List[Dict[str, Any]] = []
686
+ equity_rows: List[Dict[str, Any]] = []
687
+
688
+ for market_id, snap in markets.items():
689
+ for t in (snap.get("openTrades") or []) + (snap.get("closedTrades") or []):
690
+ src_id = t.get("id")
691
+ if src_id is None:
692
+ continue
693
+ trades_rows.append({
694
+ "market_id": market_id,
695
+ "source_id": int(src_id),
696
+ "symbol": t.get("symbol"),
697
+ "side": t.get("side"),
698
+ "entry_date": t.get("entry_date"),
699
+ "entry_price": t.get("entry_price"),
700
+ "exit_date": t.get("exit_date"),
701
+ "exit_price": t.get("exit_price"),
702
+ "quantity": int(t.get("quantity") or 0),
703
+ "entry_commission": t.get("entry_commission"),
704
+ "exit_commission": t.get("exit_commission"),
705
+ "gross_pnl": t.get("gross_pnl"),
706
+ "net_pnl": t.get("net_pnl"),
707
+ "return_pct": t.get("return_pct"),
708
+ "holding_days": t.get("holding_days"),
709
+ "signal_confidence": t.get("signal_confidence"),
710
+ "predicted_return": t.get("predicted_return"),
711
+ "entry_reason": t.get("entry_reason"),
712
+ "exit_reason": t.get("exit_reason"),
713
+ "is_closed": bool(t.get("is_closed")),
714
+ "updated_at": datetime.now(timezone.utc).isoformat(),
715
+ })
716
+
717
+ for s in (snap.get("signals") or []):
718
+ sym = s.get("symbol")
719
+ dt = s.get("date")
720
+ if not sym or not dt:
721
+ continue
722
+ signals_rows.append({
723
+ "market_id": market_id,
724
+ "date": dt,
725
+ "symbol": sym,
726
+ "signal": s.get("signal"),
727
+ "confidence": s.get("confidence"),
728
+ "predicted_return": s.get("predicted_return"),
729
+ "ml_signal": s.get("ml_signal"),
730
+ "tech_signal": s.get("tech_signal"),
731
+ "action": s.get("action"),
732
+ })
733
+
734
+ for e in (snap.get("equityCurve") or []):
735
+ dt = e.get("date")
736
+ if not dt:
737
+ continue
738
+ equity_rows.append({
739
+ "market_id": market_id,
740
+ "date": dt,
741
+ "equity": e.get("equity"),
742
+ "cash": e.get("cash"),
743
+ "positions_count": e.get("positions_count") or e.get("positions") or 0,
744
+ "unrealized_pnl": e.get("unrealized_pnl"),
745
+ "realized_pnl_today": e.get("realized_pnl_today") or e.get("realizedPnl"),
746
+ "updated_at": datetime.now(timezone.utc).isoformat(),
747
+ })
748
+
749
+ _post_rows("worker_trades", trades_rows, on_conflict="market_id,source_id")
750
+ _post_rows("worker_signals", signals_rows, on_conflict="market_id,date,symbol")
751
+ _post_rows("worker_equity", equity_rows, on_conflict="market_id,date")
752
+
753
+
754
  class TradingWorker:
755
  """Production trading worker with full safety stack.
756