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:
parent
98ef7701d1
commit
298d245048
@ -94,8 +94,8 @@ def get_database_url(cfg: dict[str, str | int] | None = None) -> str:
|
|||||||
|
|
||||||
def _create_engine() -> Engine:
|
def _create_engine() -> Engine:
|
||||||
cfg = _db_config()
|
cfg = _db_config()
|
||||||
pool_size = int(os.getenv("DB_POOL_SIZE", os.getenv("DB_POOL_MIN", "5")))
|
pool_size = int(os.getenv("DB_POOL_SIZE", os.getenv("DB_POOL_MIN", "20")))
|
||||||
max_overflow = int(os.getenv("DB_POOL_MAX", "10"))
|
max_overflow = int(os.getenv("DB_POOL_MAX", "30"))
|
||||||
pool_timeout = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
pool_timeout = int(os.getenv("DB_POOL_TIMEOUT", "30"))
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
get_database_url(cfg),
|
get_database_url(cfg),
|
||||||
|
|||||||
@ -1093,12 +1093,23 @@ def _issue_message(event: str, message: str | None, data: dict | None, meta: dic
|
|||||||
|
|
||||||
if event == "SIP_NO_FILL":
|
if event == "SIP_NO_FILL":
|
||||||
if reason_key == "insufficient_funds":
|
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":
|
if reason_key == "broker_auth_expired":
|
||||||
return "Broker session expired. Reconnect broker."
|
return "Broker session expired. Reconnect broker."
|
||||||
if reason_key == "no_fill":
|
if reason_key == "no_fill":
|
||||||
return "Order was not filled."
|
return "Order was not filled. The strategy will retry at the next interval."
|
||||||
return f"SIP not executed: {_humanize_reason(reason) or 'Unknown reason'}."
|
return f"SIP not executed: {_humanize_reason(reason) or 'Unknown reason'}. The strategy will retry automatically."
|
||||||
|
|
||||||
if event == "BROKER_AUTH_EXPIRED":
|
if event == "BROKER_AUTH_EXPIRED":
|
||||||
return "Broker session expired. Reconnect broker."
|
return "Broker session expired. Reconnect broker."
|
||||||
@ -1238,10 +1249,15 @@ def get_strategy_summary(user_id: str):
|
|||||||
'ORDER_REJECTED',
|
'ORDER_REJECTED',
|
||||||
'ORDER_CANCELLED'
|
'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
|
ORDER BY ts DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
(user_id, run_id),
|
(user_id, run_id, user_id, run_id),
|
||||||
)
|
)
|
||||||
issue_row = cur.fetchone()
|
issue_row = cur.fetchone()
|
||||||
|
|
||||||
|
|||||||
@ -53,8 +53,8 @@ def _db_config():
|
|||||||
def _init_pool():
|
def _init_pool():
|
||||||
config = _db_config()
|
config = _db_config()
|
||||||
config = {k: v for k, v in config.items() if v is not None}
|
config = {k: v for k, v in config.items() if v is not None}
|
||||||
minconn = int(os.getenv("DB_POOL_MIN", "1"))
|
minconn = int(os.getenv("DB_POOL_MIN", "2"))
|
||||||
maxconn = int(os.getenv("DB_POOL_MAX", "10"))
|
maxconn = int(os.getenv("DB_POOL_MAX", "50"))
|
||||||
if "dsn" in config:
|
if "dsn" in config:
|
||||||
return pool.ThreadedConnectionPool(minconn, maxconn, dsn=config["dsn"])
|
return pool.ThreadedConnectionPool(minconn, maxconn, dsn=config["dsn"])
|
||||||
return pool.ThreadedConnectionPool(minconn, maxconn, **config)
|
return pool.ThreadedConnectionPool(minconn, maxconn, **config)
|
||||||
|
|||||||
@ -371,6 +371,9 @@ def _record_reconciliation_event(cur, event: str, *, logical_time, payload: dict
|
|||||||
return
|
return
|
||||||
if event in {"SIP_EXECUTED", "SIP_PARTIAL", "SIP_NO_FILL", "ORDER_RECONCILIATION_PENDING"}:
|
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)
|
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
|
return
|
||||||
insert_engine_event(cur, event, data=payload, ts=ts)
|
insert_engine_event(cur, event, data=payload, ts=ts)
|
||||||
|
|
||||||
@ -680,6 +683,14 @@ def _try_execute_sip_paper(
|
|||||||
funds = broker.get_funds(cur=cur)
|
funds = broker.get_funds(cur=cur)
|
||||||
cash = funds.get("cash")
|
cash = funds.get("cash")
|
||||||
if cash is not None and float(cash) < sip_amount_val:
|
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
|
return state, False
|
||||||
|
|
||||||
log_event(
|
log_event(
|
||||||
|
|||||||
@ -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)
|
_pause_for_auth_expiry(scope_user, scope_run, str(exc), emit_event_cb=emit_event_cb)
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
log_event("PRICE_FETCH_ERROR", {"error": str(exc)})
|
||||||
debug_event("PRICE_FETCH_ERROR", "live price fetch failed", {"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):
|
if not sleep_with_heartbeat(30, stop_event, scope_user, scope_run, owner_id):
|
||||||
exit_reason = "LEASE_LOST"
|
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)
|
_pause_for_auth_expiry(scope_user, scope_run, str(exc), emit_event_cb=emit_event_cb)
|
||||||
break
|
break
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
log_event("HISTORY_LOAD_ERROR", {"error": str(exc)})
|
||||||
debug_event("HISTORY_LOAD_ERROR", "history load failed", {"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):
|
if not sleep_with_heartbeat(30, stop_event, scope_user, scope_run, owner_id):
|
||||||
exit_reason = "LEASE_LOST"
|
exit_reason = "LEASE_LOST"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user