Skip to content

Commit c7efa11

Browse files
committed
Fix: untracked broker positions + 0 DTE display
Two related fixes that surfaced from a real trade through the platform that ended up tagged 'External broker position': 1. Position-cache semantics. _fetch_broker_positions used to return fresh=True on cache hits, which caused the destructive cleanup loop in get_position_details to delete tracked positions whenever the cache happened to be older than the position's creation. Symptom: open a position via the platform, broker fills it, position written to our DB, next /api/position poll deletes it because the cached broker snapshot (5s old) didn't see it yet. The position then shows as 'External broker position' because it exists at broker but not in our DB. Renamed the flag to just_fetched and only set it True on actual broker fetches, not cache hits. 2. Auto-import untracked broker option positions. Even with the cache fix, any future bug class that breaks position-creation would leave the user with unmanageable positions. Now: when get_position_details sees an option position at the broker that isn't in our DB, it self-imports it as a tracked position with conservative defaults (no SL/TP, dte=0). User can drag SL/TP pills on the chart to attach exits. 3. DTE display polish in PositionTracker. position.dte || 'N/A' was conflating 'missing dte' with 'dte=0', so 0 DTE positions (a primary use case) showed 'N/A'. Swapped to ?? so only null/undefined falls back. Real 0 now displays as 0. Verified: - Auto-import test: untracked broker option appears, no 'External broker position' tag, X close button visible. - Cache test: 4/4 cases pass — first call just_fetched=True, cache hit just_fetched=False (the fix), TTL expiry re-fetches, broker failure serves stale with just_fetched=False.
1 parent 1cd2a32 commit c7efa11

2 files changed

Lines changed: 70 additions & 17 deletions

File tree

assisted_trading/backend/trading_engine.py

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -58,16 +58,29 @@ def _fetch_broker_positions(self):
5858
"""Get broker positions through a short TTL cache with graceful fallback.
5959
6060
Returns:
61-
(positions: list, fresh: bool)
62-
fresh=True → call just succeeded (or cache hit within TTL of a success)
63-
fresh=False → broker call failed; we're serving stale data. Callers
64-
MUST NOT use this as evidence that positions were closed.
61+
(positions: list, just_fetched: bool)
62+
just_fetched=True → we just hit the broker and got an authoritative
63+
response in this call.
64+
just_fetched=False → we're serving cached or stale data. The cache
65+
may be up to TTL seconds old, OR the last
66+
broker call failed. Either way, callers MUST
67+
NOT do destructive reconciliation against this
68+
data — a position might exist on the broker
69+
but not in this cached snapshot.
70+
71+
NB: the previous version of this method returned `fresh=True` on cache
72+
hits, which caused get_position_details() to delete tracked positions
73+
whenever the cache happened to be older than the position's creation.
74+
That manifested as "position opened via platform shows as External
75+
broker position" — the position got written to our DB, then the very
76+
next /api/position poll deleted it because the stale cache didn't see
77+
it on the broker yet. Renaming the flag is the fix.
6578
"""
6679
now = time.time()
6780
cache = self._positions_cache
6881

6982
if cache['fresh'] and (now - cache['fetched_at']) < self._positions_cache_ttl:
70-
return cache['data'], True
83+
return cache['data'], False # cache hit — NOT just-fetched
7184

7285
try:
7386
positions = self.broker.get_all_positions()
@@ -83,8 +96,6 @@ def _fetch_broker_positions(self):
8396
return positions, True
8497
except Exception as e:
8598
self._positions_failure_streak += 1
86-
# Log loudly on first failure of a streak, then suppress duplicates.
87-
# Every 30 consecutive failures, emit one reminder.
8899
if self._positions_failure_streak == 1:
89100
logger.warning(
90101
"Failed to get broker positions (serving cached/stale data): %s", e
@@ -1055,25 +1066,67 @@ def get_position_details(self) -> list:
10551066
positions_with_details = []
10561067

10571068
# Get broker positions through the cached/error-tolerant accessor.
1058-
# `fresh=True` means we just got an authoritative answer from the broker.
1059-
# `fresh=False` means we're serving stale cache (broker call failed) — in
1060-
# that case we MUST NOT do destructive reconciliation, because we can't
1061-
# actually tell which positions still exist on the broker.
1062-
broker_positions, fresh = self._fetch_broker_positions()
1069+
# `just_fetched=True` only when this call hit the broker NOW.
1070+
# Cache hits and stale-after-failure both return False — destructive
1071+
# reconciliation against either is unsafe (we'd delete tracked
1072+
# positions that exist but the cached snapshot didn't see yet).
1073+
broker_positions, just_fetched = self._fetch_broker_positions()
10631074
broker_symbols = {pos['symbol'] for pos in broker_positions}
10641075

10651076
# Clean up tracked positions that no longer exist in broker — ONLY when
1066-
# we just got a fresh authoritative answer. A transient network blip
1067-
# must never wipe out our local tracking (and the server-side stops
1068-
# attached to those positions).
1069-
if fresh:
1077+
# we just got a fresh authoritative answer from the broker right now.
1078+
# Stale cache / transient network errors must never wipe out our local
1079+
# tracking (and the server-side stops attached to those positions).
1080+
if just_fetched:
10701081
tracked_positions = self.position_manager.get_all_positions()
10711082
for position in tracked_positions:
10721083
option_symbol = position['option_symbol']
10731084
if option_symbol not in broker_symbols:
10741085
logger.info(f"Removing stale tracked position {option_symbol} - not found in broker")
10751086
self.position_manager.delete_position(option_symbol)
10761087

1088+
# Auto-import untracked broker OPTION positions. The most common cause
1089+
# of an untracked broker option position is "we placed it via the
1090+
# platform but the position-creation step missed it" — e.g. an earlier
1091+
# bug deleted it, the platform restarted between order placement and
1092+
# fill, or the order was placed before this DB existed. Self-heal by
1093+
# creating a placeholder tracked entry (no SL/TP — user can drag
1094+
# them onto the chart). Without this, the position is visible but
1095+
# unmanageable.
1096+
if just_fetched:
1097+
for broker_pos in broker_positions:
1098+
opt_sym = broker_pos.get('symbol', '')
1099+
if not self._is_option_symbol(opt_sym):
1100+
continue # stocks aren't ours to manage
1101+
if self.position_manager.has_position(opt_sym):
1102+
continue
1103+
try:
1104+
underlying = self._parse_underlying_from_option(opt_sym)
1105+
strike, ctype = self._parse_option_details(opt_sym)
1106+
qty = int(broker_pos.get('qty', 0) or 0)
1107+
if qty <= 0:
1108+
continue
1109+
self.position_manager.open_position(
1110+
option_symbol=opt_sym,
1111+
symbol=underlying,
1112+
contract_type=ctype,
1113+
strike=strike,
1114+
dte=0, # unknown without parsing OCC date; UI accepts 0
1115+
total_contracts=qty,
1116+
entry_price=float(broker_pos.get('avg_entry_price', 0.0) or 0.0),
1117+
underlying_entry_price=float(broker_pos.get('current_price', 0.0) or 0.0),
1118+
stop_loss_price=None,
1119+
take_profit_price=None,
1120+
source_order_id=None,
1121+
)
1122+
logger.info(
1123+
"Auto-imported untracked broker option position: %s (%d contracts) — "
1124+
"drag SL/TP on the chart to attach exits",
1125+
opt_sym, qty,
1126+
)
1127+
except Exception as e:
1128+
logger.warning("Auto-import failed for %s: %s", opt_sym, e)
1129+
10771130
# Track which broker positions we've processed
10781131
processed_broker_symbols = set()
10791132

assisted_trading/frontendv2/js/trading/PositionTracker.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ class PositionTracker {
209209
</div>
210210
<div class="detail-item">
211211
<div class="detail-label">DTE</div>
212-
<div class="detail-value">${position.dte || 'N/A'}</div>
212+
<div class="detail-value">${position.dte ?? 'N/A'}</div>
213213
</div>
214214
<div class="detail-item">
215215
<div class="detail-label">Contracts</div>

0 commit comments

Comments
 (0)