Replace threshold placeholder link with a real settings panel
Adds a "Loss thresholds" panel to the popup backed by the backend's new GET/PATCH /me/settings endpoints, converting between the wire's fractional percentages and whole-number form inputs only at that boundary. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01RTorFQXMR9ZQtCKcMTXLHp
This commit is contained in:
@@ -93,6 +93,7 @@ Note the naming mismatch with the wire protocol below: the frontend's internal `
|
|||||||
- `src/background/wallet-auth.ts` — `handleWalletConnected()` runs the REST auth flow (`POST /auth/nonce` → Phantom signature → `POST /auth/verify` → session token) once a content script reports a connected wallet. Also `requestWalletReconnect()` (silent reconnect after the backend invalidates a session), `requestWalletDisconnect()` (sign-out), `requestAccountSwitch()` (explicit account switch) — all three just message whichever tabs are on a supported site; `wallet-connect.ts` in the content script does the actual work.
|
- `src/background/wallet-auth.ts` — `handleWalletConnected()` runs the REST auth flow (`POST /auth/nonce` → Phantom signature → `POST /auth/verify` → session token) once a content script reports a connected wallet. Also `requestWalletReconnect()` (silent reconnect after the backend invalidates a session), `requestWalletDisconnect()` (sign-out), `requestAccountSwitch()` (explicit account switch) — all three just message whichever tabs are on a supported site; `wallet-connect.ts` in the content script does the actual work.
|
||||||
- `src/background/ws-client.ts` — the WS client described above: connects to `/ws?token=...`. `connectWsClient()` returns a controller with `reconnectNow()` so the background script can short-circuit the backoff wait right after a fresh token arrives. Auth failures (`4001` close, `auth_expired`/`session_revoked` errors) do **not** auto-retry with backoff — they call `onAuthExpired()` instead, since retrying with a known-bad token can't succeed; only real disconnects (network drop, backgrounded browser) use the protocol's suggested backoff schedule.
|
- `src/background/ws-client.ts` — the WS client described above: connects to `/ws?token=...`. `connectWsClient()` returns a controller with `reconnectNow()` so the background script can short-circuit the backoff wait right after a fresh token arrives. Auth failures (`4001` close, `auth_expired`/`session_revoked` errors) do **not** auto-retry with backoff — they call `onAuthExpired()` instead, since retrying with a known-bad token can't succeed; only real disconnects (network drop, backgrounded browser) use the protocol's suggested backoff schedule.
|
||||||
- `src/background/connection-status.ts` — separate from lock state; the popup surfaces this (connecting/connected/disconnected/auth-error) alongside the lock state so a broken connection isn't silently indistinguishable from "unlocked".
|
- `src/background/connection-status.ts` — separate from lock state; the popup surfaces this (connecting/connected/disconnected/auth-error) alongside the lock state so a broken connection isn't silently indistinguishable from "unlocked".
|
||||||
|
- `src/background/settings-client.ts` — `GET`/`PATCH /me/settings` (per-user loss-detection thresholds; see backend/CLAUDE.md, "Account settings"), same direct-fetch-with-bearer-token shape as `wallet-auth.ts`. Popup -> background dispatch is `nexa:get-settings`/`nexa:update-settings` in `background.ts`, responses shaped `{ settings } | { error }` (`SettingsResult` in `shared/messaging.ts`). The popup's "Loss thresholds" panel (`entrypoints/popup/App.tsx`) is the only place percent fields (`near_full_exit_fraction` and both loss thresholds) get converted between the backend's fractional wire representation and the whole-number percentages shown in the form — same "convert only at the seam" rule as `LockScope`'s kebab/snake split below.
|
||||||
|
|
||||||
### Wallet auth (Phantom) — implemented
|
### Wallet auth (Phantom) — implemented
|
||||||
|
|
||||||
@@ -183,5 +184,5 @@ Single source of truth for the wire protocol between the Nexa backend (Rust/Axum
|
|||||||
|
|
||||||
- No cost-basis tracking logic (backend concern).
|
- No cost-basis tracking logic (backend concern).
|
||||||
- No full-site-block implementation beyond a stub module.
|
- No full-site-block implementation beyond a stub module.
|
||||||
- No threshold-setting UI (placeholder link only).
|
- ~~No threshold-setting UI (placeholder link only)~~ — implemented, see "Backend connection" above.
|
||||||
- Only the axiom.trade adapter needs to be functional; the architecture just needs to make adding more sites trivial.
|
- Only the axiom.trade adapter needs to be functional; the architecture just needs to make adding more sites trivial.
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { BACKEND_HTTP_URL } from '@/shared/config';
|
||||||
|
import type { ThresholdSettings } from '@/shared/types';
|
||||||
|
import { getSessionToken } from './backend-client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET`/`PATCH /me/settings` — same direct-fetch-with-bearer-token shape as
|
||||||
|
* wallet-auth.ts's REST calls. Throws on any failure (network, auth, or a
|
||||||
|
* validation rejection from the backend); callers decide how to surface that.
|
||||||
|
*/
|
||||||
|
export async function getSettings(): Promise<ThresholdSettings> {
|
||||||
|
const token = await getSessionToken();
|
||||||
|
const res = await fetch(`${BACKEND_HTTP_URL}/me/settings`, {
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`get settings failed: ${res.status}`);
|
||||||
|
return (await res.json()) as ThresholdSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSettings(patch: Partial<ThresholdSettings>): Promise<ThresholdSettings> {
|
||||||
|
const token = await getSessionToken();
|
||||||
|
const res = await fetch(`${BACKEND_HTTP_URL}/me/settings`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = await res.text().catch(() => '');
|
||||||
|
throw new Error(message || `update settings failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as ThresholdSettings;
|
||||||
|
}
|
||||||
@@ -7,8 +7,9 @@ import {
|
|||||||
requestWalletDisconnect,
|
requestWalletDisconnect,
|
||||||
requestWalletReconnect,
|
requestWalletReconnect,
|
||||||
} from '@/background/wallet-auth';
|
} from '@/background/wallet-auth';
|
||||||
|
import { getSettings, updateSettings } from '@/background/settings-client';
|
||||||
import { connectWsClient } from '@/background/ws-client';
|
import { connectWsClient } from '@/background/ws-client';
|
||||||
import type { NexaMessage } from '@/shared/messaging';
|
import type { NexaMessage, SettingsResult } from '@/shared/messaging';
|
||||||
|
|
||||||
export default defineBackground(() => {
|
export default defineBackground(() => {
|
||||||
const ws = connectWsClient({
|
const ws = connectWsClient({
|
||||||
@@ -71,6 +72,18 @@ export default defineBackground(() => {
|
|||||||
void requestAccountSwitch();
|
void requestAccountSwitch();
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
case 'nexa:get-settings':
|
||||||
|
getSettings()
|
||||||
|
.then((settings) => sendResponse({ settings } satisfies SettingsResult))
|
||||||
|
.catch((err) => sendResponse({ error: String(err) } satisfies SettingsResult));
|
||||||
|
return true; // keep the message channel open for the async response
|
||||||
|
|
||||||
|
case 'nexa:update-settings':
|
||||||
|
updateSettings(message.settings)
|
||||||
|
.then((settings) => sendResponse({ settings } satisfies SettingsResult))
|
||||||
|
.catch((err) => sendResponse({ error: String(err) } satisfies SettingsResult));
|
||||||
|
return true;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { browser } from 'wxt/browser';
|
import { browser } from 'wxt/browser';
|
||||||
import { DEFAULT_CONNECTION_STATUS, DEFAULT_LOCK_STATE, type ConnectionStatus, type LockState } from '@/shared/types';
|
import {
|
||||||
import type { NexaMessage } from '@/shared/messaging';
|
DEFAULT_CONNECTION_STATUS,
|
||||||
|
DEFAULT_LOCK_STATE,
|
||||||
|
DEFAULT_THRESHOLD_SETTINGS,
|
||||||
|
type ConnectionStatus,
|
||||||
|
type LockState,
|
||||||
|
type ThresholdSettings,
|
||||||
|
} from '@/shared/types';
|
||||||
|
import type { NexaMessage, SettingsResult } from '@/shared/messaging';
|
||||||
|
|
||||||
const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
|
const CONNECTION_LABELS: Record<ConnectionStatus, string> = {
|
||||||
connecting: 'Connecting…',
|
connecting: 'Connecting…',
|
||||||
@@ -78,13 +85,193 @@ export function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<a
|
<ThresholdsPanel />
|
||||||
className="thresholds-link"
|
|
||||||
href="#"
|
|
||||||
title="Threshold settings are not part of this milestone"
|
|
||||||
>
|
|
||||||
Adjust loss thresholds →
|
|
||||||
</a>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percent-shaped fields (`near_full_exit_fraction`, both loss thresholds) are
|
||||||
|
* edited here as whole numbers (e.g. "30") and converted to/from the
|
||||||
|
* backend's fractional wire representation only at this boundary — same
|
||||||
|
* "convert only at the seam" rule already applied to LockScope's kebab/snake
|
||||||
|
* case split (see ../../CLAUDE.md).
|
||||||
|
*/
|
||||||
|
type ThresholdForm = {
|
||||||
|
exitPercent: string;
|
||||||
|
windowHours: string;
|
||||||
|
streakLength: string;
|
||||||
|
singleLossPercent: string;
|
||||||
|
streakLossPercent: string;
|
||||||
|
cooldownMinutes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toForm(settings: ThresholdSettings): ThresholdForm {
|
||||||
|
return {
|
||||||
|
exitPercent: String(Math.round(settings.near_full_exit_fraction * 100)),
|
||||||
|
windowHours: String(settings.loss_window_hours),
|
||||||
|
streakLength: String(settings.streak_length),
|
||||||
|
singleLossPercent: String(Math.round(-settings.single_trade_loss_threshold * 100)),
|
||||||
|
streakLossPercent: String(Math.round(-settings.streak_sum_loss_threshold * 100)),
|
||||||
|
cooldownMinutes: String(settings.cooldown_minutes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromForm(form: ThresholdForm): Partial<ThresholdSettings> {
|
||||||
|
return {
|
||||||
|
near_full_exit_fraction: Number(form.exitPercent) / 100,
|
||||||
|
loss_window_hours: Number(form.windowHours),
|
||||||
|
streak_length: Number(form.streakLength),
|
||||||
|
single_trade_loss_threshold: -Number(form.singleLossPercent) / 100,
|
||||||
|
streak_sum_loss_threshold: -Number(form.streakLossPercent) / 100,
|
||||||
|
cooldown_minutes: Number(form.cooldownMinutes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThresholdsPanel() {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [form, setForm] = useState<ThresholdForm>(() => toForm(DEFAULT_THRESHOLD_SETTINGS));
|
||||||
|
const [status, setStatus] = useState<'idle' | 'loading' | 'saving'>('idle');
|
||||||
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
|
function expand(): void {
|
||||||
|
setExpanded(true);
|
||||||
|
if (status !== 'idle') return;
|
||||||
|
setStatus('loading');
|
||||||
|
setError(undefined);
|
||||||
|
browser.runtime
|
||||||
|
.sendMessage({ type: 'nexa:get-settings' } satisfies NexaMessage)
|
||||||
|
.then((result: SettingsResult | undefined) => {
|
||||||
|
if (result && 'settings' in result) {
|
||||||
|
setForm(toForm(result.settings));
|
||||||
|
setStatus('idle');
|
||||||
|
} else {
|
||||||
|
setError(result?.error ?? 'Failed to load settings.');
|
||||||
|
setStatus('idle');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(key: keyof ThresholdForm, value: string): void {
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(): void {
|
||||||
|
setStatus('saving');
|
||||||
|
setError(undefined);
|
||||||
|
browser.runtime
|
||||||
|
.sendMessage({ type: 'nexa:update-settings', settings: fromForm(form) } satisfies NexaMessage)
|
||||||
|
.then((result: SettingsResult | undefined) => {
|
||||||
|
setStatus('idle');
|
||||||
|
if (result && 'settings' in result) {
|
||||||
|
setForm(toForm(result.settings));
|
||||||
|
} else {
|
||||||
|
setError(result?.error ?? 'Failed to save settings.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expanded) {
|
||||||
|
return (
|
||||||
|
<button type="button" className="thresholds-toggle" onClick={expand}>
|
||||||
|
Adjust loss thresholds →
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="thresholds-panel">
|
||||||
|
<h2>Loss thresholds</h2>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Exit counts as "full" at
|
||||||
|
<div className="threshold-input">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={form.exitPercent}
|
||||||
|
onChange={(e) => field('exitPercent', e.target.value)}
|
||||||
|
/>
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Streak window
|
||||||
|
<div className="threshold-input">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={168}
|
||||||
|
value={form.windowHours}
|
||||||
|
onChange={(e) => field('windowHours', e.target.value)}
|
||||||
|
/>
|
||||||
|
hours
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Streak length
|
||||||
|
<div className="threshold-input">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={20}
|
||||||
|
value={form.streakLength}
|
||||||
|
onChange={(e) => field('streakLength', e.target.value)}
|
||||||
|
/>
|
||||||
|
trades
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Single-trade loss locks at
|
||||||
|
<div className="threshold-input">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={form.singleLossPercent}
|
||||||
|
onChange={(e) => field('singleLossPercent', e.target.value)}
|
||||||
|
/>
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Streak cumulative loss locks at
|
||||||
|
<div className="threshold-input">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={form.streakLossPercent}
|
||||||
|
onChange={(e) => field('streakLossPercent', e.target.value)}
|
||||||
|
/>
|
||||||
|
%
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Auto-unlock after
|
||||||
|
<div className="threshold-input">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1440}
|
||||||
|
value={form.cooldownMinutes}
|
||||||
|
onChange={(e) => field('cooldownMinutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
minutes
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <p className="thresholds-error">{error}</p>}
|
||||||
|
|
||||||
|
<button type="button" className="sign-out-button" onClick={save} disabled={status === 'saving'}>
|
||||||
|
{status === 'saving' ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,11 +100,65 @@ header .subtitle {
|
|||||||
background: rgba(128, 128, 128, 0.12);
|
background: rgba(128, 128, 128, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thresholds-link {
|
.thresholds-toggle {
|
||||||
display: block;
|
display: block;
|
||||||
|
width: 100%;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
opacity: 0.5;
|
opacity: 0.75;
|
||||||
text-decoration: none;
|
font: inherit;
|
||||||
pointer-events: none;
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thresholds-toggle:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thresholds-panel {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid rgba(128, 128, 128, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thresholds-panel h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thresholds-panel label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold-input input {
|
||||||
|
width: 48px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.4);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thresholds-error {
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
color: #dc2626;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ConnectionStatus, LockState } from './types';
|
import type { ConnectionStatus, LockState, ThresholdSettings } from './types';
|
||||||
|
|
||||||
/** browser.storage.local key backing the lock state seam in the background script. */
|
/** browser.storage.local key backing the lock state seam in the background script. */
|
||||||
export const LOCK_STATE_STORAGE_KEY = 'nexa:lockState';
|
export const LOCK_STATE_STORAGE_KEY = 'nexa:lockState';
|
||||||
@@ -28,7 +28,14 @@ export type NexaMessage =
|
|||||||
| { type: 'nexa:switch-account-request' }
|
| { type: 'nexa:switch-account-request' }
|
||||||
// Sign-out / switch account (popup -> background). See entrypoints/popup/App.tsx.
|
// Sign-out / switch account (popup -> background). See entrypoints/popup/App.tsx.
|
||||||
| { type: 'nexa:sign-out' }
|
| { type: 'nexa:sign-out' }
|
||||||
| { type: 'nexa:switch-account' };
|
| { type: 'nexa:switch-account' }
|
||||||
|
// Loss-threshold settings (popup -> background -> GET/PATCH /me/settings).
|
||||||
|
// See background/settings-client.ts and entrypoints/popup/App.tsx.
|
||||||
|
| { type: 'nexa:get-settings' }
|
||||||
|
| { type: 'nexa:update-settings'; settings: Partial<ThresholdSettings> };
|
||||||
|
|
||||||
|
/** Response shape for 'nexa:get-settings'/'nexa:update-settings', returned via sendResponse. */
|
||||||
|
export type SettingsResult = { settings: ThresholdSettings } | { error: string };
|
||||||
|
|
||||||
/** Response shape for 'nexa:wallet-sign-request', returned via sendResponse (not a dispatched NexaMessage). */
|
/** Response shape for 'nexa:wallet-sign-request', returned via sendResponse (not a dispatched NexaMessage). */
|
||||||
export type WalletSignResult = { signature: string } | { error: string };
|
export type WalletSignResult = { signature: string } | { error: string };
|
||||||
|
|||||||
@@ -17,3 +17,28 @@ export const DEFAULT_LOCK_STATE: LockState = {
|
|||||||
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'auth-error';
|
export type ConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'auth-error';
|
||||||
|
|
||||||
export const DEFAULT_CONNECTION_STATUS: ConnectionStatus = 'connecting';
|
export const DEFAULT_CONNECTION_STATUS: ConnectionStatus = 'connecting';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-user loss-detection thresholds — mirrors the backend's
|
||||||
|
* `settings::Settings` field-for-field (wire is snake_case throughout, see
|
||||||
|
* backend/CLAUDE.md, "Relationship to the frontend"). Fetched/updated via
|
||||||
|
* `GET`/`PATCH /me/settings`, see background/settings-client.ts.
|
||||||
|
*/
|
||||||
|
export interface ThresholdSettings {
|
||||||
|
near_full_exit_fraction: number;
|
||||||
|
loss_window_hours: number;
|
||||||
|
streak_length: number;
|
||||||
|
single_trade_loss_threshold: number;
|
||||||
|
streak_sum_loss_threshold: number;
|
||||||
|
cooldown_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mirrors the backend's `Settings::default()` — shown before the real values load. */
|
||||||
|
export const DEFAULT_THRESHOLD_SETTINGS: ThresholdSettings = {
|
||||||
|
near_full_exit_fraction: 0.95,
|
||||||
|
loss_window_hours: 24,
|
||||||
|
streak_length: 3,
|
||||||
|
single_trade_loss_threshold: -0.3,
|
||||||
|
streak_sum_loss_threshold: -0.5,
|
||||||
|
cooldown_minutes: 30,
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user