feat: wire up stop levels, circuit breaker daily loss, profit-taking, real portfolio/decisions/history endpoints
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
import { usePositions } from '../../api/hooks';
|
||||||
|
import type { Position } from '../../api/hooks';
|
||||||
import { useTradingStatus } from '../../api/tradingHooks';
|
import { useTradingStatus } from '../../api/tradingHooks';
|
||||||
import { Card, LoadingSpinner } from '../../components/ui';
|
import { Card, LoadingSpinner } from '../../components/ui';
|
||||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
@@ -8,57 +10,54 @@ const COLORS = [
|
|||||||
'#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b',
|
'#ec4899', '#8b5cf6', '#14b8a6', '#f97316', '#64748b',
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Position {
|
|
||||||
ticker: string;
|
|
||||||
entry_price: number;
|
|
||||||
current_price: number;
|
|
||||||
unrealized_pnl: number;
|
|
||||||
stop_loss: number | null;
|
|
||||||
take_profit: number | null;
|
|
||||||
sector: string | null;
|
|
||||||
quantity: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fmtUsd(v: number | null | undefined) {
|
function fmtUsd(v: number | null | undefined) {
|
||||||
if (v == null) return '—';
|
if (v == null) return '—';
|
||||||
return `$${v.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
return `$${Number(v).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pnlColor(v: number | null | undefined) {
|
function pnlColor(v: number | null | undefined) {
|
||||||
if (v == null) return 'text-gray-400';
|
if (v == null) return 'text-gray-400';
|
||||||
return v >= 0 ? 'text-green-400' : 'text-red-400';
|
return Number(v) >= 0 ? 'text-green-400' : 'text-red-400';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PortfolioComposition() {
|
export function PortfolioComposition() {
|
||||||
const { data: status, isLoading } = useTradingStatus();
|
const { data: positions, isLoading: posLoading } = usePositions();
|
||||||
|
const { data: status } = useTradingStatus();
|
||||||
|
|
||||||
// Extract positions from the status — the API may embed them or we derive from open_position_count
|
const tickerData = useMemo(() => {
|
||||||
// For now, we use a typed cast since the status object may carry positions in an extended response
|
if (!positions) return [];
|
||||||
const positions: Position[] = useMemo(() => {
|
return positions.map((p: Position) => ({
|
||||||
if (!status) return [];
|
name: p.ticker,
|
||||||
const raw = (status as unknown as Record<string, unknown>)['positions'];
|
value: Math.abs(Number(p.quantity) * Number(p.avg_entry_price)),
|
||||||
if (Array.isArray(raw)) return raw as Position[];
|
}));
|
||||||
return [];
|
|
||||||
}, [status]);
|
|
||||||
|
|
||||||
const sectorData = useMemo(() => {
|
|
||||||
const map = new Map<string, number>();
|
|
||||||
for (const p of positions) {
|
|
||||||
const sector = p.sector ?? 'Unknown';
|
|
||||||
const value = (p.current_price ?? 0) * (p.quantity ?? 0);
|
|
||||||
map.set(sector, (map.get(sector) ?? 0) + value);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).map(([name, value]) => ({ name, value }));
|
|
||||||
}, [positions]);
|
}, [positions]);
|
||||||
|
|
||||||
if (isLoading) return <LoadingSpinner />;
|
const totalInvested = useMemo(() => {
|
||||||
|
if (!positions) return 0;
|
||||||
|
return positions.reduce((sum: number, p: Position) => sum + Math.abs(Number(p.quantity) * Number(p.avg_entry_price)), 0);
|
||||||
|
}, [positions]);
|
||||||
|
|
||||||
|
const totalUnrealized = useMemo(() => {
|
||||||
|
if (!positions) return 0;
|
||||||
|
return positions.reduce((sum: number, p: Position) => sum + Number(p.unrealized_pnl ?? 0), 0);
|
||||||
|
}, [positions]);
|
||||||
|
|
||||||
|
if (posLoading) return <LoadingSpinner />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
<StatCard label="Active Pool" value={fmtUsd(status?.active_pool)} />
|
||||||
|
<StatCard label="Reserve Pool" value={fmtUsd(status?.reserve_pool)} />
|
||||||
|
<StatCard label="Total Invested" value={fmtUsd(totalInvested)} />
|
||||||
|
<StatCard label="Unrealized P&L" value={fmtUsd(totalUnrealized)} color={pnlColor(totalUnrealized)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Positions Table */}
|
{/* Positions Table */}
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Current Positions</h2>
|
<h2 className="mb-3 text-sm font-medium text-gray-400">Current Positions ({positions?.length ?? 0})</h2>
|
||||||
{positions.length === 0 ? (
|
{!positions?.length ? (
|
||||||
<p className="text-sm text-gray-500">No open positions</p>
|
<p className="text-sm text-gray-500">No open positions</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -66,40 +65,48 @@ export function PortfolioComposition() {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-surface-700 text-left text-gray-500">
|
<tr className="border-b border-surface-700 text-left text-gray-500">
|
||||||
<th className="px-3 py-2">Ticker</th>
|
<th className="px-3 py-2">Ticker</th>
|
||||||
|
<th className="px-3 py-2">Qty</th>
|
||||||
<th className="px-3 py-2">Entry</th>
|
<th className="px-3 py-2">Entry</th>
|
||||||
<th className="px-3 py-2">Current</th>
|
<th className="px-3 py-2">Current</th>
|
||||||
|
<th className="px-3 py-2">Market Value</th>
|
||||||
<th className="px-3 py-2">Unrealized P&L</th>
|
<th className="px-3 py-2">Unrealized P&L</th>
|
||||||
<th className="px-3 py-2">Stop-Loss</th>
|
<th className="px-3 py-2">Return %</th>
|
||||||
<th className="px-3 py-2">Take-Profit</th>
|
|
||||||
<th className="px-3 py-2">Sector</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{positions.map((p) => (
|
{positions.map((p: Position) => {
|
||||||
<tr key={p.ticker} className="border-b border-surface-700/50">
|
const qty = Number(p.quantity);
|
||||||
|
const entry = Number(p.avg_entry_price);
|
||||||
|
const current = Number(p.current_price ?? entry);
|
||||||
|
const marketValue = qty * current;
|
||||||
|
const pnl = Number(p.unrealized_pnl ?? 0);
|
||||||
|
const returnPct = entry > 0 ? ((current - entry) / entry) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<tr key={p.id} className="border-b border-surface-700/50">
|
||||||
<td className="px-3 py-2 font-mono font-semibold text-brand-300">{p.ticker}</td>
|
<td className="px-3 py-2 font-mono font-semibold text-brand-300">{p.ticker}</td>
|
||||||
<td className="px-3 py-2 text-gray-300">{fmtUsd(p.entry_price)}</td>
|
<td className="px-3 py-2 text-gray-300">{qty}</td>
|
||||||
<td className="px-3 py-2 text-gray-300">{fmtUsd(p.current_price)}</td>
|
<td className="px-3 py-2 text-gray-300">{fmtUsd(entry)}</td>
|
||||||
<td className={`px-3 py-2 ${pnlColor(p.unrealized_pnl)}`}>{fmtUsd(p.unrealized_pnl)}</td>
|
<td className="px-3 py-2 text-gray-300">{fmtUsd(current)}</td>
|
||||||
<td className="px-3 py-2 text-gray-300">{fmtUsd(p.stop_loss)}</td>
|
<td className="px-3 py-2 text-gray-300">{fmtUsd(marketValue)}</td>
|
||||||
<td className="px-3 py-2 text-gray-300">{fmtUsd(p.take_profit)}</td>
|
<td className={`px-3 py-2 ${pnlColor(pnl)}`}>{fmtUsd(pnl)}</td>
|
||||||
<td className="px-3 py-2 text-gray-400">{p.sector ?? '—'}</td>
|
<td className={`px-3 py-2 ${pnlColor(returnPct)}`}>{returnPct.toFixed(2)}%</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Sector Allocation Pie Chart */}
|
{/* Allocation Pie Chart */}
|
||||||
{sectorData.length > 0 && (
|
{tickerData.length > 0 && (
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="mb-3 text-sm font-medium text-gray-400">Sector Allocation</h2>
|
<h2 className="mb-3 text-sm font-medium text-gray-400">Position Allocation</h2>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<PieChart>
|
<PieChart>
|
||||||
<Pie
|
<Pie
|
||||||
data={sectorData}
|
data={tickerData}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
nameKey="name"
|
nameKey="name"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
@@ -107,7 +114,7 @@ export function PortfolioComposition() {
|
|||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
label={({ name, percent }) => `${name} ${((percent ?? 0) * 100).toFixed(0)}%`}
|
||||||
>
|
>
|
||||||
{sectorData.map((_, i) => (
|
{tickerData.map((_, i) => (
|
||||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
@@ -123,3 +130,12 @@ export function PortfolioComposition() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, color = 'text-gray-100' }: { label: string; value: string; color?: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="text-center">
|
||||||
|
<div className={`text-lg font-bold ${color}`}>{value}</div>
|
||||||
|
<div className="text-xs text-gray-500">{label}</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+77
-2
@@ -363,10 +363,59 @@ async def set_capital(body: CapitalRequest) -> dict[str, Any]:
|
|||||||
async def list_decisions(
|
async def list_decisions(
|
||||||
ticker: Optional[str] = None,
|
ticker: Optional[str] = None,
|
||||||
decision: Optional[str] = None,
|
decision: Optional[str] = None,
|
||||||
|
is_micro_trade: Optional[bool] = None,
|
||||||
limit: int = Query(default=50, le=200),
|
limit: int = Query(default=50, le=200),
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return recent trading decisions (placeholder — paginated)."""
|
"""Return recent trading decisions from the database."""
|
||||||
|
if engine is None or engine.pool is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
conditions = ["1=1"]
|
||||||
|
params: list[Any] = []
|
||||||
|
idx = 1
|
||||||
|
|
||||||
|
if ticker:
|
||||||
|
conditions.append(f"ticker = ${idx}")
|
||||||
|
params.append(ticker.upper())
|
||||||
|
idx += 1
|
||||||
|
if decision:
|
||||||
|
conditions.append(f"decision = ${idx}")
|
||||||
|
params.append(decision)
|
||||||
|
idx += 1
|
||||||
|
if is_micro_trade is not None:
|
||||||
|
conditions.append(f"is_micro_trade = ${idx}")
|
||||||
|
params.append(is_micro_trade)
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
where = " AND ".join(conditions)
|
||||||
|
params.extend([limit, offset])
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = await engine.pool.fetch(
|
||||||
|
f"SELECT id, recommendation_id, decision, skip_reason, ticker, "
|
||||||
|
f"computed_position_size, computed_share_quantity, "
|
||||||
|
f"risk_tier_at_decision, portfolio_heat_at_decision, "
|
||||||
|
f"active_pool_at_decision, reserve_pool_at_decision, "
|
||||||
|
f"circuit_breaker_status, is_micro_trade, created_at "
|
||||||
|
f"FROM trading_decisions WHERE {where} "
|
||||||
|
f"ORDER BY created_at DESC LIMIT ${idx} OFFSET ${idx + 1}",
|
||||||
|
*params,
|
||||||
|
)
|
||||||
|
from decimal import Decimal as _Dec
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
for k, v in d.items():
|
||||||
|
if isinstance(v, _Dec):
|
||||||
|
d[k] = float(v)
|
||||||
|
elif isinstance(v, datetime):
|
||||||
|
d[k] = v.isoformat()
|
||||||
|
elif hasattr(v, '__str__') and not isinstance(v, (str, int, float, bool, type(None))):
|
||||||
|
d[k] = str(v)
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@@ -401,7 +450,33 @@ async def current_metrics() -> dict[str, Any]:
|
|||||||
async def metrics_history(
|
async def metrics_history(
|
||||||
limit: int = Query(default=30, le=365),
|
limit: int = Query(default=30, le=365),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Return historical daily snapshots (placeholder)."""
|
"""Return historical daily portfolio snapshots."""
|
||||||
|
if engine is None or engine.pool is None:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = await engine.pool.fetch(
|
||||||
|
"SELECT id, snapshot_date, portfolio_value, active_pool, reserve_pool, "
|
||||||
|
"daily_return, cumulative_return, unrealized_pnl, realized_pnl, "
|
||||||
|
"win_count, loss_count, win_rate, sharpe_ratio, max_drawdown, "
|
||||||
|
"current_drawdown_pct, portfolio_heat, risk_tier, created_at "
|
||||||
|
"FROM portfolio_snapshots ORDER BY snapshot_date DESC LIMIT $1",
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
from decimal import Decimal as _Dec
|
||||||
|
result = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
for k, v in d.items():
|
||||||
|
if isinstance(v, _Dec):
|
||||||
|
d[k] = float(v)
|
||||||
|
elif isinstance(v, datetime):
|
||||||
|
d[k] = v.isoformat()
|
||||||
|
elif hasattr(v, '__str__') and not isinstance(v, (str, int, float, bool, type(None))):
|
||||||
|
d[k] = str(v)
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -786,6 +786,33 @@ class TradingEngine:
|
|||||||
self.portfolio_state.active_pool if self.portfolio_state else 0,
|
self.portfolio_state.active_pool if self.portfolio_state else 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Create stop-loss levels for the new position
|
||||||
|
if self.pool is not None:
|
||||||
|
try:
|
||||||
|
price = rec.get("current_price", 0.0)
|
||||||
|
atr_est = price * 0.02 # 2% ATR estimate
|
||||||
|
tier = self._active_risk_tier
|
||||||
|
sl_price = price * (1 - 0.02 * tier.stop_loss_atr_multiplier)
|
||||||
|
tp_price = price * (1 + 0.02 * tier.stop_loss_atr_multiplier * tier.reward_risk_ratio)
|
||||||
|
await self.pool.execute(
|
||||||
|
"INSERT INTO position_stop_levels "
|
||||||
|
"(ticker, entry_price, stop_loss_price, take_profit_price, "
|
||||||
|
"trailing_stop_active, atr_value, atr_multiplier, "
|
||||||
|
"reward_risk_ratio, signal_confidence, is_micro_trade, active) "
|
||||||
|
"VALUES ($1, $2, $3, $4, FALSE, $5, $6, $7, $8, FALSE, TRUE) "
|
||||||
|
"ON CONFLICT (ticker) WHERE active = TRUE DO UPDATE SET "
|
||||||
|
"entry_price = EXCLUDED.entry_price, "
|
||||||
|
"stop_loss_price = EXCLUDED.stop_loss_price, "
|
||||||
|
"take_profit_price = EXCLUDED.take_profit_price, "
|
||||||
|
"updated_at = NOW()",
|
||||||
|
decision.ticker, price, sl_price, tp_price,
|
||||||
|
atr_est, tier.stop_loss_atr_multiplier,
|
||||||
|
tier.reward_risk_ratio,
|
||||||
|
rec.get("confidence", 0.8),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not create stop levels for %s", decision.ticker)
|
||||||
|
|
||||||
# Persist decision
|
# Persist decision
|
||||||
await self._persist_decision(decision)
|
await self._persist_decision(decision)
|
||||||
|
|
||||||
@@ -955,6 +982,18 @@ class TradingEngine:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.debug("Could not refresh correlation matrix")
|
logger.debug("Could not refresh correlation matrix")
|
||||||
|
|
||||||
|
# Profit-taking: sell positions that have exceeded the take-profit threshold
|
||||||
|
try:
|
||||||
|
await self._check_profit_taking()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not run profit-taking check")
|
||||||
|
|
||||||
|
# Circuit breaker: check daily loss threshold
|
||||||
|
try:
|
||||||
|
await self._check_circuit_breaker_daily_loss()
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not run circuit breaker daily loss check")
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
break
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1588,6 +1627,137 @@ class TradingEngine:
|
|||||||
logger.debug("Could not load stop levels — table may not exist")
|
logger.debug("Could not load stop levels — table may not exist")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
async def _check_circuit_breaker_daily_loss(self) -> None:
|
||||||
|
"""Check if daily unrealized loss exceeds the circuit breaker threshold.
|
||||||
|
|
||||||
|
If the portfolio has lost more than circuit_breaker_daily_loss_pct
|
||||||
|
(default 5%) from its start-of-day value, activate the circuit breaker
|
||||||
|
to halt all new trades.
|
||||||
|
"""
|
||||||
|
if self.pool is None or self.portfolio_state is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Already active — nothing to do
|
||||||
|
if self._cb_state.active:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get total unrealized P&L from positions
|
||||||
|
row = await self.pool.fetchrow(
|
||||||
|
"SELECT COALESCE(SUM(unrealized_pnl), 0) AS total_pnl FROM positions WHERE quantity > 0"
|
||||||
|
)
|
||||||
|
total_pnl = float(row["total_pnl"]) if row else 0.0
|
||||||
|
total_value = self.portfolio_state.total_value
|
||||||
|
|
||||||
|
if total_value <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
daily_loss_pct = abs(min(0, total_pnl)) / total_value
|
||||||
|
|
||||||
|
# Check against threshold (default 5%)
|
||||||
|
threshold = getattr(self.config, 'circuit_breaker_daily_loss_pct', 0.05) or 0.05
|
||||||
|
if isinstance(threshold, str):
|
||||||
|
threshold = float(threshold)
|
||||||
|
|
||||||
|
if daily_loss_pct >= threshold:
|
||||||
|
# Activate circuit breaker
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
cooldown_hours = getattr(self.config, 'circuit_breaker_volatility_pause_hours', 2) or 2
|
||||||
|
cooldown_expires = now + timedelta(hours=int(cooldown_hours))
|
||||||
|
|
||||||
|
self._cb_state = CircuitBreakerState(
|
||||||
|
active=True,
|
||||||
|
trigger_type="daily_loss_limit",
|
||||||
|
triggered_at=now,
|
||||||
|
cooldown_expires=cooldown_expires,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist to DB
|
||||||
|
try:
|
||||||
|
await self.pool.execute(
|
||||||
|
"INSERT INTO circuit_breaker_events "
|
||||||
|
"(trigger_type, triggered_at, cooldown_expires, trigger_data) "
|
||||||
|
"VALUES ($1, $2, $3, $4)",
|
||||||
|
"daily_loss_limit",
|
||||||
|
now,
|
||||||
|
cooldown_expires,
|
||||||
|
json.dumps({
|
||||||
|
"daily_loss_pct": round(daily_loss_pct, 4),
|
||||||
|
"threshold": threshold,
|
||||||
|
"total_pnl": round(total_pnl, 2),
|
||||||
|
"total_value": round(total_value, 2),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not persist circuit breaker event")
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"CIRCUIT BREAKER ACTIVATED: daily loss %.1f%% exceeds %.1f%% threshold "
|
||||||
|
"(P&L=$%.2f, value=$%.2f). Trading halted until %s",
|
||||||
|
daily_loss_pct * 100, threshold * 100,
|
||||||
|
total_pnl, total_value, cooldown_expires.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create alert
|
||||||
|
self.create_alert(
|
||||||
|
"circuit_breaker_activated",
|
||||||
|
f"Daily loss {daily_loss_pct:.1%} exceeded {threshold:.1%} threshold. "
|
||||||
|
f"Trading halted until {cooldown_expires.strftime('%H:%M ET')}.",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not check circuit breaker daily loss")
|
||||||
|
|
||||||
|
async def _check_profit_taking(self) -> None:
|
||||||
|
"""Check positions for profit-taking opportunities.
|
||||||
|
|
||||||
|
Sells positions that have gained more than the take-profit threshold
|
||||||
|
defined by the risk tier's reward_risk_ratio. For moderate tier with
|
||||||
|
2.0 ATR stop and 1.5 reward/risk, that's roughly a 3% gain target.
|
||||||
|
|
||||||
|
Also sells positions that have gained > 10% regardless of tier
|
||||||
|
(absolute profit cap to lock in gains).
|
||||||
|
"""
|
||||||
|
if self.pool is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = await self.pool.fetch(
|
||||||
|
"SELECT ticker, quantity, avg_entry_price, current_price, unrealized_pnl "
|
||||||
|
"FROM positions WHERE quantity > 0"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
ticker = row["ticker"]
|
||||||
|
qty = int(row["quantity"])
|
||||||
|
entry = float(row["avg_entry_price"])
|
||||||
|
current = float(row["current_price"] or 0)
|
||||||
|
|
||||||
|
if entry <= 0 or current <= 0 or qty <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
gain_pct = (current - entry) / entry
|
||||||
|
|
||||||
|
# Absolute profit cap: sell if gain > 10%
|
||||||
|
# Or tier-based: reward_risk_ratio * stop_loss_atr_multiplier * ~2% base ATR
|
||||||
|
tier_target = self._active_risk_tier.reward_risk_ratio * self._active_risk_tier.stop_loss_atr_multiplier * 0.02
|
||||||
|
should_sell = gain_pct >= 0.10 or gain_pct >= tier_target
|
||||||
|
|
||||||
|
if should_sell:
|
||||||
|
logger.info(
|
||||||
|
"Profit-taking: %s gained %.1f%% (target=%.1f%%) — selling %d shares",
|
||||||
|
ticker, gain_pct * 100, max(10.0, tier_target * 100), qty,
|
||||||
|
)
|
||||||
|
await self._submit_sell_order(ticker, qty, f"profit_taking_{gain_pct:.1%}")
|
||||||
|
|
||||||
|
# Update portfolio state
|
||||||
|
if self.portfolio_state:
|
||||||
|
self.portfolio_state.open_position_count = max(
|
||||||
|
0, self.portfolio_state.open_position_count - 1
|
||||||
|
)
|
||||||
|
self.portfolio_state.active_pool += current * qty
|
||||||
|
|
||||||
async def _submit_sell_order(
|
async def _submit_sell_order(
|
||||||
self, ticker: str, quantity: int, reason: str
|
self, ticker: str, quantity: int, reason: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user