fix(exec): code-review serale — guard anti-posizione-nuda sul netting + verità per-frazione
7 finder paralleli sul diff della giornata (8adf388..HEAD), fix dei confermati: CRITICI (money-path): - close_amount: GUARD stato-stantio sul fallback netting — il residuo non-reduce-only e' consentito solo fino al gap (conto reale - libri degli altri worker - orfani) nella direzione della chiusura (src/live/books.py = fonte unica, usata anche dal reconciler). Senza guard, un close su stato stantio (DSL scattato in outage, flatten manuale) APRIVA una posizione nuda a taglia piena bookata come 'chiusura verificata'. Fail-safe se il gap non e' calcolabile. Check polvere PRIMA di _quantize_step (che clampa al lotto minimo: un residuo 1e-17 diventava un ordine nudo da un lotto). - _real_close: market_amt = filled_amount anche a merged verified=False (i contratti chiusi dal reduce-only non si buttano se il leg netting fallisce); REAL_CLOSE_PARTIAL non piu' gateato su verified (era soppresso proprio nel caso parziale reale). - pairs: verita' per-FRAZIONE di gamba (gross proporzionale al fillato, orfano = solo il residuo — prima falsava reconciler e real_capital della parte gia' chiusa); REAL_OPEN_PAIR booka filled_amount; docstring applied corretta. - open_pair unwind: chiude il FILLATO, non il richiesto (senza il cap silenzioso del reduce-only avrebbe mangiato quota altrui). - place_tp_limit: quantize CONSERVATIVO (sell=floor/buy=ceil) — il rounding al tick piu' vicino poteva mettere il resting oltre il TP sim -> tocco genuino classificato phantom sistematicamente. ROBUSTEZZA/OSSERVABILITA': - runner: WORKER_ERROR_STREAK a 5 e poi ogni 50 poll + recovery RIPRESO + flag real_in_position nell'alert (prima: one-shot a n==5, poi silenzio). - _tp_phantom: TTL 120s sul verdetto (era ~50 HTTP/h per worker a barra fantasma); merge notes con entrambi gli order_id (audit-trail). - reset_flatten: _quantize_step Decimal (round float produceva amount che Deribit rifiuta); hourly_report: book XS01 con gambe SHORT visibili (abs). - validate_xsec_worker: POS/LEV importati (non hardcoded); xs01_tranche: regression-lock ESEGUITO vs xsec_sim; reconcile su src/live/books; drift_monitor: rolling vettoriale + exit code 1 su warn. Test: +4 guard/dust, fixture filled_amount -> 114 passed. Deferiti (TODO): resting esposti al netting, lifecycle orphan_legs, finestra trade-history TP_PHANTOM, validazione feed a monte, dedup minori. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
"""Libri REALI per strumento dai status.json persistiti dei worker — fonte unica.
|
||||
|
||||
Usato da:
|
||||
- scripts/analysis/reconcile_account.py (audit orario conto vs libri)
|
||||
- ExecutionClient.close_amount (guard del netting: il residuo non-reduce-only
|
||||
e' consentito solo fino al gap conto-vs-libri-altrui — senza questo guard un
|
||||
close su stato stantio APRIREBBE una posizione nuda, code-review 2026-06-11)
|
||||
|
||||
Convenzione: amount firmato in base-coin (buy=+, sell=-), strumenti USDC lineari.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
PAPER = PROJECT_ROOT / "data" / "portfolio_paper"
|
||||
|
||||
|
||||
def _inst(asset: str) -> str:
|
||||
return f"{asset}_USDC-PERPETUAL"
|
||||
|
||||
|
||||
def real_books(exclude_worker: str | None = None) -> tuple[dict[str, float], dict[str, float]]:
|
||||
"""(libri per strumento, orfani registrati per strumento), firmati.
|
||||
|
||||
exclude_worker: worker_id da ESCLUDERE dai libri (per il guard del netting:
|
||||
il close del worker X deve portare il conto al netto degli ALTRI). Gli
|
||||
orphan_legs NON vengono mai esclusi: sono posizioni che il conto ha ancora
|
||||
indipendentemente da chi le ha originate."""
|
||||
books: dict[str, float] = {}
|
||||
orphans: dict[str, float] = {}
|
||||
for sp in sorted(PAPER.glob("*/status.json")):
|
||||
wid = sp.parent.name
|
||||
try:
|
||||
st = json.loads(sp.read_text())
|
||||
except Exception:
|
||||
continue
|
||||
if st.get("real_in_position") and wid != exclude_worker:
|
||||
parts = wid.split("__")
|
||||
if st.get("real_amount_a"):
|
||||
a, b = parts[1].split("_") # pairs: …__ETH_BTC__1h
|
||||
for asset, side, amt in ((a, st["real_side_a"], st["real_amount_a"]),
|
||||
(b, st["real_side_b"], st["real_amount_b"])):
|
||||
sgn = 1 if side == "buy" else -1
|
||||
books[_inst(asset)] = books.get(_inst(asset), 0.0) + sgn * amt
|
||||
elif st.get("real_amount"):
|
||||
sgn = 1 if st.get("real_side") == "buy" else -1
|
||||
books[_inst(parts[1])] = books.get(_inst(parts[1]), 0.0) + sgn * st["real_amount"]
|
||||
for o in st.get("orphan_legs", []):
|
||||
sgn = 1 if o.get("entry_side") == "buy" else -1
|
||||
orphans[o["instrument"]] = orphans.get(o["instrument"], 0.0) + sgn * float(o["amount"])
|
||||
return books, orphans
|
||||
|
||||
|
||||
def account_net(client) -> dict[str, float]:
|
||||
"""Posizioni reali per strumento dal conto (size USD / mark -> coin, firmato)."""
|
||||
out: dict[str, float] = {}
|
||||
for p in client.get_positions(currency="USDC") or []:
|
||||
inst = p.get("instrument")
|
||||
size = float(p.get("size") or 0)
|
||||
mark = float(p.get("mark_price") or 0)
|
||||
if not inst or not size or not mark:
|
||||
continue
|
||||
amt = size / mark
|
||||
out[inst] = amt if p.get("direction") == "long" else -amt
|
||||
return out
|
||||
+57
-8
@@ -76,13 +76,24 @@ def _quantize_step(value: float, step: float, mn: float) -> float:
|
||||
return float(max(n_steps * Decimal(str(step)), Decimal(str(mn))))
|
||||
|
||||
|
||||
def quantize_price(instrument: str, price: float) -> float:
|
||||
def quantize_price(instrument: str, price: float, side: str | None = None) -> float:
|
||||
"""Arrotonda il prezzo al tick dello strumento (Decimal, niente artefatti
|
||||
float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick)."""
|
||||
float) — richiesto per gli ordini limit (Deribit rifiuta prezzi off-tick).
|
||||
|
||||
side (code-review 2026-06-11): per un limit di USCITA il rounding al tick
|
||||
piu' vicino puo' mettere il resting OLTRE il livello sim → un tocco esatto
|
||||
del livello non filla MAI il resting e il gate TP_PHANTOM classificherebbe
|
||||
phantom un tocco genuino, sistematicamente. 'sell' = floor (mai sopra il
|
||||
livello), 'buy' = ceil (mai sotto): il resting e' sempre raggiungibile
|
||||
quando il sim dichiara il tocco (costo: <=1 tick di PnL vs sim)."""
|
||||
import math
|
||||
tick = contract_spec(instrument).get("tick")
|
||||
if not tick or price <= 0:
|
||||
return price
|
||||
return float(round(price / tick) * Decimal(str(tick)))
|
||||
n = (math.floor(price / tick) if side == "sell"
|
||||
else math.ceil(price / tick) if side == "buy"
|
||||
else round(price / tick))
|
||||
return float(n * Decimal(str(tick)))
|
||||
|
||||
|
||||
def notional_to_amount(instrument: str, notional_usd: float,
|
||||
@@ -143,8 +154,8 @@ def _merge_close_fills(ro: Fill, net: Fill, requested: float) -> Fill:
|
||||
ro.fee_coin + net.fee_coin, ro.fee_usd + net.fee_usd,
|
||||
net.order_id or ro.order_id, net.order_state or ro.order_state,
|
||||
verified, raw={"reduce_only": ro.raw, "net": net.raw},
|
||||
notes=(f"netting: reduce-only {fa}/{requested}, residuo {fb} "
|
||||
f"market puro ({net.order_state})"),
|
||||
notes=(f"netting: reduce-only {fa}/{requested} (oid {ro.order_id}), "
|
||||
f"residuo {fb} market puro ({net.order_state}, oid {net.order_id})"),
|
||||
filled_amount=tot)
|
||||
|
||||
|
||||
@@ -316,13 +327,48 @@ class ExecutionClient:
|
||||
fill = self._submit(instrument, opp, amount, 0.0,
|
||||
reduce_only=True, label=label)
|
||||
residual = amount - fill.filled_amount
|
||||
residual = _quantize_step(residual, spec["step"], spec["min"]) if residual > 0 else 0.0
|
||||
# check sulla POLVERE prima di quantizzare: _quantize_step clampa al lotto
|
||||
# minimo, quindi un residuo da artefatto float (1e-17) diventerebbe un
|
||||
# ordine nudo da un lotto intero (code-review 2026-06-11)
|
||||
if residual < spec["step"] / 2:
|
||||
return fill
|
||||
residual = _quantize_step(residual, spec["step"], spec["min"])
|
||||
# GUARD stato-stantio (code-review 2026-06-11): il residuo NON-reduce-only
|
||||
# e' consentito solo fino al gap (conto reale − libri degli ALTRI worker −
|
||||
# orfani) nella direzione della chiusura. Se la nostra quota non esiste
|
||||
# piu' sul conto (disaster-SL scattato in outage, flatten manuale, stato
|
||||
# stantio al resume) il gap e' ~0 → NIENTE ordine nudo: si ritorna il
|
||||
# parziale onesto (il chiamante alza REAL_CLOSE_PARTIAL). Se il guard non
|
||||
# e' calcolabile (rete) si resta FAIL-SAFE: meglio un orfano tracciato
|
||||
# che una posizione nuda non tracciata.
|
||||
allowed = self._net_close_allowance(instrument, opp, label)
|
||||
if allowed is None or allowed < spec["step"] / 2:
|
||||
fill.notes = (fill.notes + " | " if fill.notes else "") + (
|
||||
f"netting NEGATO: residuo {residual} ma gap conto-libri "
|
||||
f"{'non calcolabile' if allowed is None else round(allowed, 6)} "
|
||||
"(stato stantio? quota gia' chiusa?)")
|
||||
return fill
|
||||
if allowed < residual:
|
||||
residual = _quantize_step(allowed, spec["step"], spec["min"])
|
||||
net = self._submit(instrument, opp, residual, 0.0, reduce_only=False,
|
||||
label=f"{label}|net" if label else "net")
|
||||
return _merge_close_fills(fill, net, amount)
|
||||
|
||||
def _net_close_allowance(self, instrument: str, close_side: str,
|
||||
self_worker: str | None) -> float | None:
|
||||
"""Quanto residuo non-reduce-only e' GIUSTIFICATO dai libri: gap firmato
|
||||
fra il conto reale e (libri degli altri worker + orfani registrati),
|
||||
proiettato nella direzione della chiusura. None = non calcolabile."""
|
||||
try:
|
||||
from src.live.books import real_books, account_net
|
||||
books, orphans = real_books(exclude_worker=self_worker)
|
||||
target = books.get(instrument, 0.0) + orphans.get(instrument, 0.0)
|
||||
pos = account_net(self.client).get(instrument, 0.0)
|
||||
gap = pos - target # quota nostra ancora sul conto (firmata)
|
||||
return max(0.0, gap if close_side == "sell" else -gap)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def place_tp_limit(self, instrument: str, entry_side: str, amount: float,
|
||||
tp_price: float, label: str | None = None) -> Fill:
|
||||
"""LIMIT reduce-only appoggiato al livello TP (lato opposto all'entrata):
|
||||
@@ -333,7 +379,7 @@ class ExecutionClient:
|
||||
TP il limit crossa e filla subito (state 'filled'); la riconciliazione a
|
||||
valle (resting_fills) lo gestisce come un fill normale."""
|
||||
opp = "sell" if entry_side == "buy" else "buy"
|
||||
px = quantize_price(instrument, tp_price)
|
||||
px = quantize_price(instrument, tp_price, side=opp)
|
||||
if amount <= 0 or px <= 0:
|
||||
return Fill(instrument, opp, 0.0, amount, None, 0.0, 0.0,
|
||||
None, None, False, notes="amount/tp non validi")
|
||||
@@ -474,7 +520,10 @@ class PairsExecutionClient:
|
||||
unwound = False
|
||||
for f, inst in ((fa, inst_a), (fb, inst_b)):
|
||||
if f.verified and f.amount > 0:
|
||||
self.leg.close_amount(inst, f.side, f.amount, label=label)
|
||||
# unwind del FILLATO, non del richiesto: col fallback netting un
|
||||
# amount sovrastimato non viene piu' cappato in silenzio dal
|
||||
# reduce-only — chiuderebbe quota altrui (code-review 2026-06-11)
|
||||
self.leg.close_amount(inst, f.side, f.filled_amount, label=label)
|
||||
unwound = True
|
||||
return PairFill(False, fa, fb, unwound=unwound,
|
||||
notes=f"leg-fail (a={fa.verified} b={fb.verified}), unwound={unwound}")
|
||||
|
||||
+34
-13
@@ -224,7 +224,9 @@ class PairsWorker:
|
||||
self.real_in_position = True
|
||||
self.real_dir = d
|
||||
self.real_side_a, self.real_side_b = pf.leg_a.side, pf.leg_b.side
|
||||
self.real_amount_a, self.real_amount_b = pf.leg_a.amount, pf.leg_b.amount
|
||||
# amount FILLATO, non richiesto (coerente con strategy_worker, 2026-06-11)
|
||||
self.real_amount_a = pf.leg_a.filled_amount or pf.leg_a.amount
|
||||
self.real_amount_b = pf.leg_b.filled_amount or pf.leg_b.amount
|
||||
self.real_entry_a = pf.leg_a.fill_price or sim_a
|
||||
self.real_entry_b = pf.leg_b.fill_price or sim_b
|
||||
self.real_notional_a = pf.leg_a.amount * self.real_entry_a
|
||||
@@ -240,11 +242,12 @@ class PairsWorker:
|
||||
|
||||
def _real_close_pair(self, sim_a: float, sim_b: float, reason: str,
|
||||
sim_pnl: float) -> tuple[float | None, bool]:
|
||||
"""Chiusura REALE shadow: richiude entrambe le gambe reduce-only, riconcilia
|
||||
PnL reale (per gamba) e fee, aggiorna il ledger reale parallelo.
|
||||
"""Chiusura REALE shadow: richiude entrambe le gambe (netting-aware),
|
||||
riconcilia PnL reale per-gamba e fee, aggiorna il ledger reale parallelo.
|
||||
|
||||
Ritorna (real_pnl, applied): applied=True se almeno una gamba ha chiuso con
|
||||
fill verificato (PnL reale utilizzabile come verita' in real-truth)."""
|
||||
Ritorna (real_pnl, applied): applied=True SOLO se ENTRAMBE le gambe hanno
|
||||
chiuso per intero con fill verificato — con una gamba orfana il "PnL dello
|
||||
spread" non esiste e real-truth ricade sul sim DICHIARATO."""
|
||||
if not self.real_in_position:
|
||||
return None, False
|
||||
pf = self.executor.close_pair(self.inst_a, self.inst_b, self.real_side_a,
|
||||
@@ -257,12 +260,25 @@ class PairsWorker:
|
||||
# reale (3 PnL fantasma il 2026-06-11, gamba ETH orfana sul conto).
|
||||
# Ora: si booka SOLO il realizzato delle gambe con fill verificato; la gamba
|
||||
# respinta diventa un ORFANO registrato (persistito) + alert Telegram.
|
||||
ok_a, ok_b = bool(pf.leg_a.verified), bool(pf.leg_b.verified)
|
||||
from src.live.execution import contract_spec
|
||||
for leg in (pf.leg_a, pf.leg_b):
|
||||
if "netting" in (getattr(leg, "notes", "") or ""):
|
||||
# reduce-only cappato/respinto, residuo in market puro (v1.1.25)
|
||||
self._log("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes})
|
||||
self._notify("NET_CLOSE", {"instrument": leg.instrument, "note": leg.notes})
|
||||
# verita' per-FRAZIONE di gamba (code-review 2026-06-11): una gamba puo'
|
||||
# chiudere PARZIALMENTE (reduce-only cappato + netting negato/fallito) —
|
||||
# si booka il gross della sola frazione FILLATA e l'orfano registra il
|
||||
# solo RESIDUO (prima: gross binario tutto-o-niente e orfano a amount
|
||||
# pieno, che falsava reconciler e real_capital della parte gia' chiusa).
|
||||
filled_a = min(getattr(pf.leg_a, "filled_amount", 0.0), self.real_amount_a)
|
||||
filled_b = min(getattr(pf.leg_b, "filled_amount", 0.0), self.real_amount_b)
|
||||
step_a = contract_spec(self.inst_a).get("step", 0.001)
|
||||
step_b = contract_spec(self.inst_b).get("step", 0.001)
|
||||
ok_a = filled_a >= self.real_amount_a - step_a / 2
|
||||
ok_b = filled_b >= self.real_amount_b - step_b / 2
|
||||
frac_a = filled_a / self.real_amount_a if self.real_amount_a else 0.0
|
||||
frac_b = filled_b / self.real_amount_b if self.real_amount_b else 0.0
|
||||
exit_a = pf.leg_a.fill_price or sim_a
|
||||
exit_b = pf.leg_b.fill_price or sim_b
|
||||
# PnL per gamba: dir A = +d (long ratio compra A), dir B = -d
|
||||
@@ -270,26 +286,31 @@ class PairsWorker:
|
||||
gross_a = da * (exit_a - self.real_entry_a) / self.real_entry_a * self.real_notional_a
|
||||
gross_b = db * (exit_b - self.real_entry_b) / self.real_entry_b * self.real_notional_b
|
||||
exit_fee = pf.leg_a.fee_usd + pf.leg_b.fee_usd
|
||||
real_pnl = ((gross_a if ok_a else 0.0) + (gross_b if ok_b else 0.0)
|
||||
real_pnl = (gross_a * frac_a + gross_b * frac_b
|
||||
- self.real_entry_fee - exit_fee)
|
||||
self.real_capital += real_pnl
|
||||
self.real_trades += 1
|
||||
self._log("REAL_CLOSE_PAIR", {
|
||||
"reason": reason, "exit_a": exit_a, "exit_b": exit_b,
|
||||
"leg_a_ok": ok_a, "leg_b_ok": ok_b,
|
||||
"filled_a": filled_a, "filled_b": filled_b,
|
||||
"real_pnl_usd": round(real_pnl, 4), "sim_pnl_usd": round(sim_pnl, 4),
|
||||
"entry_fee": round(self.real_entry_fee, 5), "exit_fee": round(exit_fee, 5),
|
||||
"real_capital": round(self.real_capital, 4), "verified": pf.verified})
|
||||
for ok, inst, side, amt in ((ok_a, self.inst_a, self.real_side_a, self.real_amount_a),
|
||||
(ok_b, self.inst_b, self.real_side_b, self.real_amount_b)):
|
||||
if not ok and amt > 0:
|
||||
orphan = {"instrument": inst, "entry_side": side, "amount": amt,
|
||||
for ok, inst, side, amt, filled, step in (
|
||||
(ok_a, self.inst_a, self.real_side_a, self.real_amount_a, filled_a, step_a),
|
||||
(ok_b, self.inst_b, self.real_side_b, self.real_amount_b, filled_b, step_b)):
|
||||
residue = amt - filled
|
||||
if not ok and residue >= step / 2:
|
||||
orphan = {"instrument": inst, "entry_side": side,
|
||||
"amount": round(residue, 8),
|
||||
"ts": datetime.now(timezone.utc).isoformat(), "reason": reason}
|
||||
self.orphan_legs.append(orphan)
|
||||
self._notify("PAIR_LEG_ORPHAN", {
|
||||
"worker": self.worker_id, **orphan,
|
||||
"note": ("gamba NON chiusa (reduce-only respinto dal netting di "
|
||||
"conto?): posizione orfana sul conto, intervento richiesto")})
|
||||
"note": ("gamba NON chiusa per il residuo indicato (netting "
|
||||
"negato/fallito): posizione orfana sul conto — "
|
||||
"risolvere e RIMUOVERE l'orfano dallo status")})
|
||||
self.real_in_position = False
|
||||
self.real_dir = 0
|
||||
self.real_side_a = self.real_side_b = ""
|
||||
|
||||
@@ -73,6 +73,7 @@ class StrategyWorker:
|
||||
self.real_first_notified = False # alert Telegram "esecuzione viva" una tantum
|
||||
self._tp_phantom_ts = 0 # dedup log TP_PHANTOM per barra (non persistito)
|
||||
self._tp_phantom_notified = False # alert Telegram una tantum per processo
|
||||
self._tp_phantom_cache = (0, 0.0) # (bar_ts, monotonic): TTL del verdetto phantom
|
||||
|
||||
self.worker_id = f"{strategy.name}__{asset}__{tf}"
|
||||
self.work_dir = data_dir / self.worker_id
|
||||
@@ -349,7 +350,9 @@ class StrategyWorker:
|
||||
|
||||
# 0) disaster bracket: via dal book PRIMA di chiudere (se la cancel fallisce
|
||||
# lo stop potrebbe essere SCATTATO durante un outage: quota gia' chiusa →
|
||||
# il market reduce-only a valle filla 0 e REAL_CLOSE esce verified=False)
|
||||
# il reduce-only a valle filla 0 e il GUARD del netting in close_amount
|
||||
# nega il residuo non-reduce-only — gap conto-vs-libri ~0 → niente
|
||||
# ordine nudo; REAL_CLOSE esce verified=False + REAL_CLOSE_PARTIAL)
|
||||
# NB: la cancel di un trigger order risponde con lo stato AL MOMENTO della
|
||||
# cancel ('untriggered' = successo, verificato su testnet: il re-cancel da'
|
||||
# order_not_found). 'order_not_found' = ordine non piu' in book (probabile
|
||||
@@ -398,20 +401,21 @@ class StrategyWorker:
|
||||
if remainder >= step / 2:
|
||||
fill = self.executor.close_amount(self.exec_instrument, self.real_side,
|
||||
remainder, label=self.worker_id)
|
||||
# amount FILLATO, non richiesto: un reduce-only cappato dal netting di conto
|
||||
# (worker long e short sullo stesso strumento) filla MENO del chiesto —
|
||||
# bookarlo pieno mentirebbe sul ledger (audit 2026-06-11: 0.078 vs 0.105)
|
||||
market_amt = fill.filled_amount if (fill and fill.verified) else 0.0
|
||||
# amount FILLATO, non richiesto (audit 2026-06-11) — e si booka anche a
|
||||
# verified=False: nel Fill merged dal netting filled_amount conta gia'
|
||||
# solo i fill RISCONTRATI; azzerarlo quando il leg residuo fallisce
|
||||
# butterebbe via contratti realmente chiusi (code-review 2026-06-11)
|
||||
market_amt = fill.filled_amount if fill else 0.0
|
||||
if fill and "netting" in (getattr(fill, "notes", "") or ""):
|
||||
# il reduce-only era cappato/respinto e il residuo e' andato in market
|
||||
# puro (netting contro quote opposte) — solo osservabilita'
|
||||
self._log("NET_CLOSE", {"note": fill.notes})
|
||||
self._notify("NET_CLOSE", {"note": fill.notes})
|
||||
if fill and fill.verified and market_amt < remainder - step / 2:
|
||||
if fill and market_amt < remainder - step / 2:
|
||||
data = {"requested": remainder, "filled": market_amt,
|
||||
"residuo_orfano": round(remainder - market_amt, 6),
|
||||
"note": ("close reduce-only cappato dal netting di conto: quota "
|
||||
"residua NON chiusa (direzioni opposte sullo strumento?)")}
|
||||
"note": ("close non completato (netting negato/leg fallito): "
|
||||
"quota residua NON chiusa — verificare col reconciler")}
|
||||
self._log("REAL_CLOSE_PARTIAL", data)
|
||||
self._notify("REAL_CLOSE_PARTIAL", data)
|
||||
|
||||
@@ -489,6 +493,12 @@ class StrategyWorker:
|
||||
if (self.direction == 1 and current_price >= self.tp) or \
|
||||
(self.direction == -1 and current_price <= self.tp):
|
||||
return False
|
||||
# TTL: il wick fantasma resta nella barra in formazione per ~1h → senza
|
||||
# cache sono ~45-55 HTTP consecutivi per worker per barra (code-review).
|
||||
# 120s di validita': un fill reale tardivo viene visto al massimo 2 min dopo.
|
||||
cache_bar, cache_t = self._tp_phantom_cache
|
||||
if cache_bar == current_ts and time.monotonic() - cache_t < 120:
|
||||
return True
|
||||
try:
|
||||
tp_amt, _, _ = self.executor.resting_fills(self.exec_instrument,
|
||||
self.real_tp_order_id)
|
||||
@@ -496,6 +506,7 @@ class StrategyWorker:
|
||||
return False
|
||||
if tp_amt > 0:
|
||||
return False
|
||||
self._tp_phantom_cache = (current_ts, time.monotonic())
|
||||
if current_ts != self._tp_phantom_ts:
|
||||
self._tp_phantom_ts = current_ts
|
||||
data = {"tp": self.tp, "price": current_price, "direction": self.direction,
|
||||
|
||||
+17
-4
@@ -475,15 +475,28 @@ def run(config_path: str = "portfolios.yml"):
|
||||
def _tick_safe(s, w):
|
||||
try:
|
||||
_tick(s, w)
|
||||
worker_err_streak.pop(s.sid, None)
|
||||
n_prev = worker_err_streak.pop(s.sid, 0)
|
||||
if n_prev >= 5:
|
||||
# recovery dichiarato (code-review: alert one-shot senza
|
||||
# ripresa = silenzio indistinguibile dal guasto)
|
||||
from src.live.telegram_notifier import notify_event
|
||||
notify_event("WORKER_ERROR_STREAK",
|
||||
{"worker": s.sid, "status": "RIPRESO",
|
||||
"dopo_streak": n_prev})
|
||||
except Exception as e:
|
||||
n = worker_err_streak.get(s.sid, 0) + 1
|
||||
worker_err_streak[s.sid] = n
|
||||
print(f"[runner] errore worker {s.sid}: {e} (streak {n}; gli altri proseguono)")
|
||||
if n == 5:
|
||||
# alert a 5 e poi ogni 50 poll (~1h): un worker rotto per
|
||||
# giorni non deve sparire dopo il primo Telegram. Include
|
||||
# se ha una posizione REALE aperta (exit non valutati!)
|
||||
if n == 5 or n % 50 == 0:
|
||||
from src.live.telegram_notifier import notify_event
|
||||
notify_event("WORKER_ERROR_STREAK",
|
||||
{"worker": s.sid, "streak": n, "errore": str(e)[:200]})
|
||||
notify_event("WORKER_ERROR_STREAK", {
|
||||
"worker": s.sid, "streak": n, "errore": str(e)[:200],
|
||||
"real_in_position": bool(getattr(w, "real_in_position", False)),
|
||||
"in_position": bool(getattr(w, "in_position", False)),
|
||||
"nota": "exit NON valutati per questo worker"})
|
||||
|
||||
for s in live_specs:
|
||||
_tick_safe(s, workers[s.sid])
|
||||
|
||||
Reference in New Issue
Block a user