Skip to content

Commit d0a68d5

Browse files
committed
Fix core cash-drag by topping up existing positions
1 parent e285857 commit d0a68d5

2 files changed

Lines changed: 203 additions & 8 deletions

File tree

main.py

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -372,15 +372,24 @@ def run_daily_test(self, test_date=None, pipeline_stats=None, backtest_signals=N
372372
open_positions_df = self.core_tracker.get_open_positions()
373373
open_symbols = set(open_positions_df['symbol']) if not open_positions_df.empty else set()
374374
max_positions = self.config.get('trading', {}).get('max_positions', 10)
375+
trading_cfg = self.config.get("trading", {}) if isinstance(self.config, dict) else {}
376+
risk_cfg = self.config.get("risk", {}) if isinstance(self.config, dict) else {}
377+
short_threshold = float(trading_cfg.get("short_threshold", -0.0))
375378
available_slots = max(0, max_positions - len(open_symbols))
376379
invested_cost = 0.0
377380
if not open_positions_df.empty:
378381
invested_cost = (open_positions_df['entry_price'] * open_positions_df['quantity']).sum()
379382

380383
available_capital = max(0.0, current_capital - invested_cost)
381384
cash_pct = (available_capital / current_capital) if current_capital else 0.0
382-
max_cash_pct = float(self.config.get("trading", {}).get("max_cash_pct", 1.0))
385+
max_cash_pct = float(trading_cfg.get("max_cash_pct", 1.0))
383386
max_cash_pct = max(0.0, min(1.0, max_cash_pct))
387+
min_weight = float(trading_cfg.get("min_total_weight", 0.0) or 0.0)
388+
min_weight = max(0.0, min(1.0, min_weight))
389+
idle_cash_caps = [float(current_capital) * max_cash_pct]
390+
if min_weight > 0.0:
391+
idle_cash_caps.append(float(current_capital) * max(0.0, 1.0 - min_weight))
392+
allowed_idle_cash = min(idle_cash_caps) if idle_cash_caps else 0.0
384393
cash_drag_excess = available_capital > 0 and cash_pct > max_cash_pct
385394

386395
if available_slots == 0 or available_capital <= 0:
@@ -401,7 +410,6 @@ def run_daily_test(self, test_date=None, pipeline_stats=None, backtest_signals=N
401410
portfolio_mgr = PortfolioManager(self.config_path)
402411
enable_shorts = bool(self.config.get("trading", {}).get("enable_shorts", False))
403412
max_shorts = int(self.config.get("trading", {}).get("max_shorts", 0))
404-
short_threshold = float(self.config.get("trading", {}).get("short_threshold", -0.0))
405413

406414
long_df = rank_df_filtered[rank_df_filtered["adjusted_score"] > 0].copy()
407415
short_df = pd.DataFrame()
@@ -453,14 +461,14 @@ def run_daily_test(self, test_date=None, pipeline_stats=None, backtest_signals=N
453461
target_portfolio["weight"] = target_portfolio["weight"] / total_weight
454462

455463
# Guardrail: enforce minimum total weight to avoid leaving too much cash idle
456-
min_weight = float(self.config.get("trading", {}).get("min_total_weight", 0.0))
457464
if min_weight > 0 and 0 < total_weight < min_weight:
458465
target_portfolio["weight"] = target_portfolio["weight"] * (min_weight / total_weight)
459466
logger.info(f"Adjusted weights from {total_weight:.2%} to {min_weight:.2%} to deploy more capital")
460467

461468
# Open NEW positions for selected stocks
462469
tp_pct = self.config.get('trading', {}).get('take_profit_pct', 0.03)
463470
new_positions = []
471+
top_up_positions = []
464472
remaining_capital = float(available_capital)
465473
for _, row in target_portfolio.iterrows():
466474
symbol = row['symbol']
@@ -521,6 +529,111 @@ def run_daily_test(self, test_date=None, pipeline_stats=None, backtest_signals=N
521529
})
522530
remaining_capital = max(0.0, remaining_capital - allocation_dollars)
523531

532+
# If older under-sized positions are occupying slots, scale into the best-held names
533+
# before leaving excess cash idle.
534+
extra_to_deploy = max(0.0, float(remaining_capital) - float(allowed_idle_cash))
535+
max_position_equity_pct = float(risk_cfg.get("max_position_equity_pct", 1.0) or 1.0)
536+
max_position_equity_pct = max(0.0, min(1.0, max_position_equity_pct))
537+
if extra_to_deploy > 0.0 and max_position_equity_pct > 0.0:
538+
open_positions_for_top_up = self.core_tracker.get_open_positions()
539+
if open_positions_for_top_up is not None and not open_positions_for_top_up.empty:
540+
top_up_df = open_positions_for_top_up.copy()
541+
top_up_df["symbol"] = top_up_df["symbol"].astype(str).str.strip().str.upper()
542+
top_up_df["side"] = top_up_df["side"].fillna("LONG").astype(str).str.upper()
543+
top_up_df["entry_price"] = pd.to_numeric(top_up_df["entry_price"], errors="coerce").fillna(0.0)
544+
top_up_df["quantity"] = pd.to_numeric(top_up_df["quantity"], errors="coerce").fillna(0.0)
545+
top_up_df["notional"] = top_up_df["entry_price"] * top_up_df["quantity"]
546+
top_up_df["adjusted_score"] = top_up_df["symbol"].map(
547+
lambda sym: float(rank_lookup.loc[sym]["adjusted_score"]) if sym in rank_lookup.index else 0.0
548+
)
549+
top_up_df["rank_priority"] = top_up_df["adjusted_score"].abs()
550+
551+
supported_mask = (
552+
((top_up_df["side"] == "LONG") & (top_up_df["adjusted_score"] > 0))
553+
| ((top_up_df["side"] == "SHORT") & (top_up_df["adjusted_score"] <= short_threshold))
554+
)
555+
top_up_candidates = top_up_df[supported_mask].copy()
556+
if top_up_candidates.empty:
557+
top_up_candidates = top_up_df.copy()
558+
559+
top_up_candidates = top_up_candidates.sort_values(
560+
["rank_priority", "adjusted_score"],
561+
ascending=[False, False],
562+
)
563+
564+
max_position_notional = float(current_capital) * max_position_equity_pct
565+
logger.warning(
566+
"Core cash drag remains %.2f with idle-cash cap %.2f; topping up existing positions.",
567+
float(remaining_capital),
568+
float(allowed_idle_cash),
569+
)
570+
for _, pos in top_up_candidates.iterrows():
571+
if extra_to_deploy <= 0.0:
572+
break
573+
574+
current_notional = float(pos.get("notional", 0.0) or 0.0)
575+
room = max(0.0, max_position_notional - current_notional)
576+
if room <= 0.0:
577+
continue
578+
579+
symbol = str(pos["symbol"]).strip().upper()
580+
side = str(pos.get("side") or "LONG").upper()
581+
price_data = pd.read_sql(
582+
"SELECT open FROM prices WHERE symbol=? AND date=?",
583+
conn,
584+
params=(symbol, test_date.strftime("%Y-%m-%d")),
585+
)
586+
if price_data.empty:
587+
continue
588+
589+
entry_price = float(price_data.iloc[0]["open"] or 0.0)
590+
if entry_price <= 0.0:
591+
continue
592+
593+
allocation_dollars = min(extra_to_deploy, room)
594+
quantity = allocation_dollars / entry_price if entry_price else 0.0
595+
if quantity <= 0.0:
596+
continue
597+
598+
added = self.core_tracker.add_to_position(
599+
symbol=symbol,
600+
add_date=test_date.strftime("%Y-%m-%d"),
601+
add_price=entry_price,
602+
quantity=quantity,
603+
side=side,
604+
)
605+
if not added:
606+
continue
607+
608+
reason = "Cash-drag top-up of existing position"
609+
if symbol in rank_lookup.index:
610+
info = rank_lookup.loc[symbol]
611+
reason = (
612+
f"Cash-drag top-up (pred {float(info['predicted_return']):.2%}, "
613+
f"adj {float(info['adjusted_score']):.2%}, rank {int(info['rank'])})"
614+
)
615+
616+
top_up_positions.append({
617+
"symbol": symbol,
618+
"side": side,
619+
"entry_price": entry_price,
620+
"target_price": added["target_price"],
621+
"quantity": quantity,
622+
"allocation_pct": (allocation_dollars / current_capital * 100) if current_capital else 0.0,
623+
"allocation_dollars": allocation_dollars,
624+
"reason": reason,
625+
})
626+
remaining_capital = max(0.0, remaining_capital - allocation_dollars)
627+
extra_to_deploy = max(0.0, extra_to_deploy - allocation_dollars)
628+
629+
if extra_to_deploy > 0.0:
630+
logger.warning(
631+
"Core cash drag persists after top-ups; %.2f cash still exceeds the idle-cash cap.",
632+
extra_to_deploy,
633+
)
634+
635+
entries_today = list(new_positions) + list(top_up_positions)
636+
524637
# Keep Core + AI strategies distinct by optionally preventing overlap.
525638
core_reserved_symbols = set(open_symbols)
526639
if target_portfolio is not None and hasattr(target_portfolio, "empty") and not target_portfolio.empty:
@@ -579,6 +692,7 @@ def run_daily_test(self, test_date=None, pipeline_stats=None, backtest_signals=N
579692
report = {
580693
'date': test_date.date(),
581694
'new_positions_opened': len(new_positions),
695+
'positions_topped_up': len(top_up_positions),
582696
'positions_closed_at_tp': len(closed_positions),
583697
'open_positions': summary['open_positions'],
584698
'realized_pnl_today': realized_today,
@@ -835,7 +949,7 @@ def _recent_price_metrics(conn_, sym_, lookback=25):
835949
report_data=report,
836950
unrealized_df=unrealized,
837951
closed_positions=closed_positions,
838-
new_positions=new_positions,
952+
new_positions=entries_today,
839953
meta_insights=meta_insights,
840954
signal_rankings=rank_df,
841955
pipeline_stats=core_pipeline_stats,

positions.py

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ def __init__(self, config_path=None, table_name="positions"):
3636
self.table_name = self._validate_table_name(table_name)
3737
self._init_tables()
3838

39+
def _target_price_for_side(self, entry_price, side):
40+
side = str(side or "LONG").upper()
41+
if side == "SHORT":
42+
return float(entry_price) * (1 - self.tp_pct)
43+
return float(entry_price) * (1 + self.tp_pct)
44+
3945
@staticmethod
4046
def _validate_table_name(name: str) -> str:
4147
# Prevent SQL injection via table_name.
@@ -86,10 +92,7 @@ def open_position(self, symbol, entry_date, entry_price, quantity, side="LONG"):
8692
if side not in {"LONG", "SHORT"}:
8793
raise ValueError(f"Unsupported side: {side}")
8894

89-
if side == "LONG":
90-
target_price = entry_price * (1 + self.tp_pct)
91-
else:
92-
target_price = entry_price * (1 - self.tp_pct)
95+
target_price = self._target_price_for_side(entry_price, side)
9396

9497
conn = sqlite3.connect(self.db_path)
9598
cursor = conn.cursor()
@@ -120,6 +123,84 @@ def open_position(self, symbol, entry_date, entry_price, quantity, side="LONG"):
120123
logger.info(f"Opened position #{position_id}: {symbol} {side} @ {entry_price:.2f}, TP @ {target_price:.2f}")
121124
return position_id
122125

126+
def add_to_position(self, symbol, add_date, add_price, quantity, side=None):
127+
"""Add capital to an existing open position and blend the average entry price."""
128+
symbol = str(symbol or "").strip().upper()
129+
if not symbol:
130+
return None
131+
132+
try:
133+
add_price = float(add_price or 0.0)
134+
quantity = float(quantity or 0.0)
135+
except (TypeError, ValueError):
136+
return None
137+
138+
if add_price <= 0.0 or quantity <= 0.0:
139+
return None
140+
141+
conn = sqlite3.connect(self.db_path)
142+
cursor = conn.cursor()
143+
existing = cursor.execute(
144+
f"""
145+
SELECT id, side, entry_date, entry_price, quantity
146+
FROM {self.table_name}
147+
WHERE symbol=? AND status='OPEN'
148+
""",
149+
(symbol,),
150+
).fetchone()
151+
152+
if not existing:
153+
conn.close()
154+
return None
155+
156+
pos_id, existing_side, existing_entry_date, existing_entry_price, existing_qty = existing
157+
existing_side = str(existing_side or "LONG").upper()
158+
if side is not None and str(side or "LONG").upper() != existing_side:
159+
conn.close()
160+
raise ValueError(f"Side mismatch for add_to_position({symbol})")
161+
162+
existing_entry_price = float(existing_entry_price or 0.0)
163+
existing_qty = float(existing_qty or 0.0)
164+
new_qty = existing_qty + quantity
165+
if new_qty <= 0.0:
166+
conn.close()
167+
return None
168+
169+
blended_entry = ((existing_entry_price * existing_qty) + (add_price * quantity)) / new_qty
170+
target_price = self._target_price_for_side(blended_entry, existing_side)
171+
172+
cursor.execute(
173+
f"""
174+
UPDATE {self.table_name}
175+
SET entry_price=?, quantity=?, target_price=?
176+
WHERE id=?
177+
""",
178+
(blended_entry, new_qty, target_price, pos_id),
179+
)
180+
conn.commit()
181+
conn.close()
182+
183+
logger.info(
184+
"Added to position #%s: %s %s +%.4f @ %.2f, new avg %.2f, TP @ %.2f",
185+
pos_id,
186+
symbol,
187+
existing_side,
188+
quantity,
189+
add_price,
190+
blended_entry,
191+
target_price,
192+
)
193+
return {
194+
"id": pos_id,
195+
"symbol": symbol,
196+
"side": existing_side,
197+
"entry_date": existing_entry_date or add_date,
198+
"entry_price": blended_entry,
199+
"quantity": new_qty,
200+
"added_quantity": quantity,
201+
"target_price": target_price,
202+
}
203+
123204
def check_and_close_positions(self, check_date=None):
124205
"""
125206
Check all open positions against the day's price data.

0 commit comments

Comments
 (0)