You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(worker): alert when a held position has no protective stop resting
The existing protective-stop alert is shaped around one cause: the
exchange's price band refuses the stop. Every other way a stop fails to
reach the exchange produced a condition row the strategy re-reported on
every tick and nothing ever escalated, so a position could sit unguarded
indefinitely without the operator being told.
Add an outcome-shaped alert beside it: whatever the cause, this position
is held and nothing is resting that would sell it. It fires off the
exit-blocked row carrying the unplaced code, gated on that row's age, so
it cannot page about a position that opened moments ago.
It fails closed on an undated span, unlike the band alert. That one has
an independent span-free signal for a permanent fault; this one does not,
and without the age it cannot be told apart from a fresh entry. The row
is re-read next tick, so a lost write costs a tick of delay, not the
alert.
It is suppressed on any tick the band alert already fired, since both
describe the same unguarded coin and the band one carries the more
specific instruction, and it takes its own throttle key so the two alerts
cannot mute each other on exactly the coin where both matter.
The band explanation is now parsed before its throttle window opens; a
detail it could not parse previously threw after the key was set, losing
that alert and muting the next hour of them.
Also closes a detached-fill leak: the reconciler's own terminal-status
set omitted the self-trade-prevention status, so such a row was never
closed and held its live slot and open exposure forever.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YRceiDYdzzHo6aLFr4sZPj
it('keys the throttle on the coin alone, with no escalation level to split',async()=>{
367
+
// Unlike the band refusal there is one thing to say here — nothing is guarding this position — so a second key segment would only split one message into two alerts an hour apart.
368
+
const{ deps, unplacedKeys }=build();
369
+
constnotify=deps.notifyProtectiveStopUnplaced;
370
+
if(!notify)thrownewError('the builder did not wire notifyProtectiveStopUnplaced');
it('omits the wanted stop when the strategy could not price one',async()=>{
415
+
// The strategy publishes `stop: null` when it could not derive a level at all, and a field reading "null" tells the operator nothing while implying a number exists.
416
+
const{ deps, events }=build();
417
+
constnotify=deps.notifyProtectiveStopUnplaced;
418
+
if(!notify)thrownewError('the builder did not wire notifyProtectiveStopUnplaced');
it('names the trailing-stop refusal and the distance Binance would not take',async()=>{
427
+
// The cause this alert exists for: a native-trail profile on a symbol whose trailingDelta filter has no step for the wanted distance never places anything, and no price move clears that. The distance is quoted in the percent the settings screen shows, not the fraction the strategy records.
428
+
const{ deps, events }=build();
429
+
constnotify=deps.notifyProtectiveStopUnplaced;
430
+
if(!notify)thrownewError('the builder did not wire notifyProtectiveStopUnplaced');
expect(why?.value).toContain('3.7% below the high');
439
+
expect(why?.value).toContain('follows the price up');
440
+
expect(why?.value).toContain("Switch this profile's protective stop mode to priced");
441
+
});
442
+
443
+
it('drops the distance from the refusal rather than quote one it cannot read',async()=>{
444
+
// `distancePct` is null whenever the strategy could not derive the distance at all, and the bag has crossed a JSON round-trip besides. The lever still has to be named, so the clause loses the number and keeps the advice.
445
+
const{ deps, events }=build();
446
+
constnotify=deps.notifyProtectiveStopUnplaced;
447
+
if(!notify)thrownewError('the builder did not wire notifyProtectiveStopUnplaced');
it('still reports the exposure when the detail bag did not survive the round-trip',async()=>{
467
+
// The duration is the whole signal and it comes off the input, not the bag, so a bag that arrived as null must cost the operator its optional fields and nothing else.
468
+
const{ deps, events }=build();
469
+
constnotify=deps.notifyProtectiveStopUnplaced;
470
+
if(!notify)thrownewError('the builder did not wire notifyProtectiveStopUnplaced');
it('says nothing about trailing stops when that is not what blocked this one',async()=>{
480
+
// The ordinary unplaced case has no named cause, and inventing one would send an operator to a setting that is not theirs: a priced-mode profile has no trail distance to change.
481
+
const{ deps, events }=build();
482
+
constnotify=deps.notifyProtectiveStopUnplaced;
483
+
if(!notify)thrownewError('the builder did not wire notifyProtectiveStopUnplaced');
// Which statuses close a DETACHED order's ledger row.
2
+
//
3
+
// `reconcileDetachedFill` is the only thing that ever stamps `closed_at` on an order whose profile was deleted. A status it does not recognise as terminal leaves the row open forever: it keeps its `orders_one_live_per_intent` live slot and keeps counting toward the account's open exposure, which backs the delete-account guard. So the set of statuses it accepts is a contract, and it must be the SHARED one — the same predicate the open-orders cache eviction and the boot reaper read. `EXPIRED_IN_MATCH` is the status that proves it: Binance stamps it when self-trade prevention kills an order, which on a shared account wallet is exactly what a sibling profile's BUY crossing our resting SELL produces.
* The production adopter with its position-side deps rigged to THROW. The detached path is the ledger half of adoption with the strategy half deliberately absent, so a regression that starts seeding cost basis or strategy state here fails loudly instead of quietly handing a deleted profile's position to a stranger.
54
+
*/
55
+
constmakeAdopter=()=>
56
+
createFillAdopter({
57
+
db: {}asnever,
58
+
chain: createChainByKey(),
59
+
logger: noopLogger,
60
+
statePort: {
61
+
mutate: ()=>{
62
+
thrownewError('detached reconcile must not touch strategy state');
63
+
},
64
+
}asunknownasStatePort,
65
+
registry: {
66
+
get: ()=>{
67
+
thrownewError('detached reconcile must not resolve a strategy');
68
+
},
69
+
},
70
+
pipelineQueue: {
71
+
add: ()=>{
72
+
thrownewError('detached reconcile must not enqueue an archive');
73
+
},
74
+
}asunknownasQueue,
75
+
symbolInfo: {
76
+
get: ()=>{
77
+
thrownewError('detached reconcile must not read symbol info');
0 commit comments