@@ -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 ,
0 commit comments