fix: surface SIP_NO_FILL warnings and prevent silent fund failures

- execution.py: dual-write SIP_NO_FILL and SIP_PARTIAL to engine_event
  so the strategy summary can surface them to users
- execution.py: emit SIP_NO_FILL event (with cash/required amounts) on
  the paper path instead of silently returning when funds are insufficient
- strategy_service.py: improve insufficient_funds message to show exact
  shortfall and reassure user that next SIP will auto-execute when funded
- strategy_service.py: clear SIP_NO_FILL warning after a successful
  SIP_TRIGGERED so it does not persist after funds are added
- runner.py: always write PRICE_FETCH_ERROR and HISTORY_LOAD_ERROR to
  engine_event regardless of ENGINE_DEBUG flag
- db.py (backend + engine): raise default pool sizes to 20/50 max
  connections to handle 100 concurrent users without pool exhaustion

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thigazhezhilan J 2026-06-04 10:04:53 +05:30
parent 98ef7701d1
commit 298d245048
5 changed files with 262 additions and 233 deletions

View File

@ -94,8 +94,8 @@ def get_database_url(cfg: dict[str, str | int] | None = None) -> str:
def _create_engine() -> Engine:
cfg = _db_config()
pool_size = int(os.getenv("DB_POOL_SIZE", os.getenv("DB_POOL_MIN", "5")))
max_overflow = int(os.getenv("DB_POOL_MAX", "10"))
pool_size = int(os.getenv("DB_POOL_SIZE", os.getenv("DB_POOL_MIN", "20")))
max_overflow = int(os.getenv("DB_POOL_MAX", "30"))
pool_timeout = int(os.getenv("DB_POOL_TIMEOUT", "30"))
engine = create_engine(
get_database_url(cfg),

View File

@ -1093,12 +1093,23 @@ def _issue_message(event: str, message: str | None, data: dict | None, meta: dic
if event == "SIP_NO_FILL":
if reason_key == "insufficient_funds":
return "Insufficient funds for this SIP."
cash = payload.get("cash")
required = payload.get("required")
if cash is not None and required is not None:
shortfall = float(required) - float(cash)
return (
f"Insufficient funds — ₹{shortfall:,.0f} short for this SIP. "
"Add funds and the next SIP will execute automatically."
)
return (
"Insufficient funds for this SIP. "
"Add funds and the next SIP will execute automatically."
)
if reason_key == "broker_auth_expired":
return "Broker session expired. Reconnect broker."
if reason_key == "no_fill":
return "Order was not filled."
return f"SIP not executed: {_humanize_reason(reason) or 'Unknown reason'}."
return "Order was not filled. The strategy will retry at the next interval."
return f"SIP not executed: {_humanize_reason(reason) or 'Unknown reason'}. The strategy will retry automatically."
if event == "BROKER_AUTH_EXPIRED":
return "Broker session expired. Reconnect broker."
@ -1238,10 +1249,15 @@ def get_strategy_summary(user_id: str):
'ORDER_REJECTED',
'ORDER_CANCELLED'
)
AND ts > COALESCE(
(SELECT MAX(ts) FROM engine_event
WHERE user_id = %s AND run_id = %s AND event = 'SIP_TRIGGERED'),
'1900-01-01'::timestamptz
)
ORDER BY ts DESC
LIMIT 1
""",
(user_id, run_id),
(user_id, run_id, user_id, run_id),
)
issue_row = cur.fetchone()

View File

@ -53,8 +53,8 @@ def _db_config():
def _init_pool():
config = _db_config()
config = {k: v for k, v in config.items() if v is not None}
minconn = int(os.getenv("DB_POOL_MIN", "1"))
maxconn = int(os.getenv("DB_POOL_MAX", "10"))
minconn = int(os.getenv("DB_POOL_MIN", "2"))
maxconn = int(os.getenv("DB_POOL_MAX", "50"))
if "dsn" in config:
return pool.ThreadedConnectionPool(minconn, maxconn, dsn=config["dsn"])
return pool.ThreadedConnectionPool(minconn, maxconn, **config)

View File

@ -371,6 +371,9 @@ def _record_reconciliation_event(cur, event: str, *, logical_time, payload: dict
return
if event in {"SIP_EXECUTED", "SIP_PARTIAL", "SIP_NO_FILL", "ORDER_RECONCILIATION_PENDING"}:
log_event(event, payload, cur=cur, ts=ts, logical_time=logical_time)
# Dual-write warning events to engine_event so the strategy summary can surface them
if event in {"SIP_NO_FILL", "SIP_PARTIAL"}:
insert_engine_event(cur, event, data=payload, ts=ts)
return
insert_engine_event(cur, event, data=payload, ts=ts)
@ -680,6 +683,14 @@ def _try_execute_sip_paper(
funds = broker.get_funds(cur=cur)
cash = funds.get("cash")
if cash is not None and float(cash) < sip_amount_val:
if not event_exists("SIP_NO_FILL", logical_time, cur=cur):
_payload = {
"reason": "insufficient_funds",
"cash": float(cash),
"required": sip_amount_val,
}
log_event("SIP_NO_FILL", _payload, cur=cur, ts=event_ts, logical_time=logical_time)
insert_engine_event(cur, "SIP_NO_FILL", data=_payload, ts=event_ts)
return state, False
log_event(

View File

@ -533,6 +533,7 @@ def _engine_loop(config, stop_event: threading.Event):
_pause_for_auth_expiry(scope_user, scope_run, str(exc), emit_event_cb=emit_event_cb)
break
except Exception as exc:
log_event("PRICE_FETCH_ERROR", {"error": str(exc)})
debug_event("PRICE_FETCH_ERROR", "live price fetch failed", {"error": str(exc)})
if not sleep_with_heartbeat(30, stop_event, scope_user, scope_run, owner_id):
exit_reason = "LEASE_LOST"
@ -556,6 +557,7 @@ def _engine_loop(config, stop_event: threading.Event):
_pause_for_auth_expiry(scope_user, scope_run, str(exc), emit_event_cb=emit_event_cb)
break
except Exception as exc:
log_event("HISTORY_LOAD_ERROR", {"error": str(exc)})
debug_event("HISTORY_LOAD_ERROR", "history load failed", {"error": str(exc)})
if not sleep_with_heartbeat(30, stop_event, scope_user, scope_run, owner_id):
exit_reason = "LEASE_LOST"