fix: scan overlap protection + snapshot routing + worker history tables
Browse filesFour 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 +40 -4
- supabase/migrations/20260521_worker_history.sql +140 -0
- trading/worker.py +169 -7
|
@@ -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 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
@@ -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.
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|