2026-01-12 22:36:10 +08:00
"""
Portfolio API routes (local-only).
Manages manual positions (user's existing holdings) and AI monitoring tasks.
"""
2026-01-14 05:29:55 +08:00
from flask import Blueprint , request , jsonify , g
2026-03-25 13:27:48 +08:00
from datetime import date , datetime , timezone
2026-01-12 22:36:10 +08:00
import json
import traceback
import time
2026-01-13 00:21:33 +08:00
import threading
2026-01-12 22:36:10 +08:00
from concurrent.futures import ThreadPoolExecutor , as_completed
from app.services.kline import KlineService
from app.utils.logger import get_logger
from app.utils.cache import CacheManager
from app.utils.db import get_db_connection
2026-01-14 05:29:55 +08:00
from app.utils.auth import login_required
2026-01-12 22:36:10 +08:00
from app.services.symbol_name import resolve_symbol_name
from app.data.market_symbols_seed import get_symbol_name as seed_get_symbol_name
logger = get_logger ( __name__ )
portfolio_bp = Blueprint ( 'portfolio' , __name__ )
kline_service = KlineService ()
cache = CacheManager ()
# Thread pool for parallel price fetching
2026-01-14 05:29:55 +08:00
# Lower concurrency to avoid triggering API limits (especially for forex/US stocks)
2026-01-13 00:21:33 +08:00
executor = ThreadPoolExecutor ( max_workers = 3 )
2026-01-14 05:29:55 +08:00
# Request interval (seconds) to avoid too frequent requests
2026-01-13 00:21:33 +08:00
REQUEST_INTERVAL = 0.3
2026-01-14 05:29:55 +08:00
# Rate limiting related
2026-01-13 00:21:33 +08:00
_request_lock = threading . Lock ()
_last_request_time = {} # {market: timestamp}
2026-01-12 22:36:10 +08:00
def _now_ts () -> int :
return int ( time . time ())
2026-03-25 13:27:48 +08:00
def _serialize_monitor_ts ( value ):
"""
2026-04-06 16:47:36 +07:00
JSON serialized monitoring time field. PostgreSQL TIMESTAMP is interpreted in UTC when there is no tz (consistent with Docker's default),
Output the ISO with Z to prevent the front end from treating the non-time zone string as local time and deviating by 8 hours.
2026-03-25 13:27:48 +08:00
"""
if value is None :
return None
if isinstance ( value , datetime ):
if value . tzinfo is None :
dt = value . replace ( tzinfo = timezone . utc )
else :
dt = value . astimezone ( timezone . utc )
s = dt . isoformat ()
if s . endswith ( "+00:00" ):
return s [: - 6 ] + "Z"
return s
if isinstance ( value , date ):
return value . isoformat ()
return value
2026-01-12 22:36:10 +08:00
def _normalize_symbol ( symbol : str ) -> str :
return ( symbol or '' ) . strip () . upper ()
def _safe_json_loads ( value , default = None ):
"""Safely parse JSON string."""
if default is None :
default = {}
if isinstance ( value , dict ):
return value
if isinstance ( value , list ):
return value
if isinstance ( value , str ) and value . strip ():
try :
return json . loads ( value )
except Exception :
return default
return default
2026-01-13 00:21:33 +08:00
def _get_single_price ( market : str , symbol : str , force_refresh : bool = False ) -> dict :
"""
Get price data for a single symbol.
2026-04-06 16:47:36 +07:00
Priority is given to using the real-time quotation API (ticker), and downgrading to minute/daily K-line data.
This allows for more real-time prices during the trading session, rather than just showing daily closing prices.
2026-01-13 00:21:33 +08:00
2026-04-06 16:47:36 +07:00
Built-in rate limit: requests for the same market must be at least REQUEST_INTERVAL seconds apart,
Avoid triggering API limits (like yfinance, Tiingo, Finnhub, etc.).
2026-01-13 00:21:33 +08:00
Args:
2026-04-06 16:47:36 +07:00
force_refresh: whether to force refresh (skip cache)
2026-01-13 00:21:33 +08:00
"""
2026-01-12 22:36:10 +08:00
try :
2026-04-06 16:47:36 +07:00
# Rate Limit: Interval between requests for the same market
2026-01-13 00:21:33 +08:00
with _request_lock :
now = time . time ()
last_time = _last_request_time . get ( market , 0 )
wait_time = REQUEST_INTERVAL - ( now - last_time )
if wait_time > 0 :
time . sleep ( wait_time )
_last_request_time [ market ] = time . time ()
2026-01-12 22:36:10 +08:00
2026-04-06 16:47:36 +07:00
# Get real-time prices using the new get_realtime_price method
2026-01-13 00:21:33 +08:00
price_data = kline_service . get_realtime_price ( market , symbol , force_refresh = force_refresh )
2026-01-12 22:36:10 +08:00
2026-01-13 00:21:33 +08:00
return {
'market' : market ,
'symbol' : symbol ,
'price' : price_data . get ( 'price' , 0 ),
'change' : price_data . get ( 'change' , 0 ),
'changePercent' : price_data . get ( 'changePercent' , 0 ),
2026-04-06 16:47:36 +07:00
'source' : price_data . get ( 'source' , 'unknown' ) # Record data sources for easy debugging
2026-01-13 00:21:33 +08:00
}
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "Failed to fetch price { market } : { symbol } - { str ( e ) } " )
return {
'market' : market ,
'symbol' : symbol ,
'price' : 0 ,
'change' : 0 ,
2026-01-13 00:21:33 +08:00
'changePercent' : 0 ,
'source' : 'error'
2026-01-12 22:36:10 +08:00
}
# ==================== Position CRUD ====================
@portfolio_bp.route ( '/positions' , methods = [ 'GET' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def get_positions ():
2026-01-14 05:29:55 +08:00
"""Get all manual positions with current prices for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
# Check if force refresh (skip cache)
2026-01-13 00:21:33 +08:00
force_refresh = request . args . get ( 'refresh' , '' ) . lower () in ( '1' , 'true' , 'yes' )
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
SELECT id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at
FROM qd_manual_positions
WHERE user_id = ?
ORDER BY id DESC
""" ,
2026-01-14 05:29:55 +08:00
( user_id ,)
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
cur . close ()
positions = []
price_futures = {}
# Prepare positions and submit price fetch tasks
for row in rows :
pos = {
'id' : row . get ( 'id' ),
'market' : row . get ( 'market' ),
'symbol' : row . get ( 'symbol' ),
'name' : row . get ( 'name' ) or row . get ( 'symbol' ),
'side' : row . get ( 'side' ) or 'long' ,
'quantity' : float ( row . get ( 'quantity' ) or 0 ),
'entry_price' : float ( row . get ( 'entry_price' ) or 0 ),
'entry_time' : row . get ( 'entry_time' ),
'notes' : row . get ( 'notes' ) or '' ,
'tags' : _safe_json_loads ( row . get ( 'tags' ), []),
'group_name' : row . get ( 'group_name' ) or '' ,
'created_at' : row . get ( 'created_at' ),
'updated_at' : row . get ( 'updated_at' ),
# Will be filled later
'current_price' : 0 ,
'price_change' : 0 ,
'price_change_percent' : 0 ,
'market_value' : 0 ,
'cost_value' : 0 ,
'pnl' : 0 ,
'pnl_percent' : 0
}
positions . append ( pos )
2026-01-13 00:21:33 +08:00
# Submit price fetch task (with force_refresh support)
2026-01-12 22:36:10 +08:00
market = row . get ( 'market' )
symbol = row . get ( 'symbol' )
if market and symbol :
key = f " { market } : { symbol } "
if key not in price_futures :
2026-01-13 00:21:33 +08:00
future = executor . submit ( _get_single_price , market , symbol , force_refresh )
2026-01-12 22:36:10 +08:00
price_futures [ key ] = future
# Collect price results
price_map = {}
for key , future in price_futures . items ():
try :
result = future . result ( timeout = 10 )
price_map [ key ] = result
except Exception as e :
logger . warning ( f "Price fetch failed for { key } : { e } " )
# Calculate PnL for each position
for pos in positions :
key = f " { pos [ 'market' ] } : { pos [ 'symbol' ] } "
price_data = price_map . get ( key , {})
current_price = float ( price_data . get ( 'price' ) or 0 )
entry_price = pos [ 'entry_price' ]
quantity = pos [ 'quantity' ]
side = pos [ 'side' ]
pos [ 'current_price' ] = current_price
pos [ 'price_change' ] = price_data . get ( 'change' , 0 )
pos [ 'price_change_percent' ] = price_data . get ( 'changePercent' , 0 )
# Calculate values
pos [ 'market_value' ] = current_price * quantity
pos [ 'cost_value' ] = entry_price * quantity
# Calculate PnL based on side
if side == 'long' :
pos [ 'pnl' ] = ( current_price - entry_price ) * quantity
else : # short
pos [ 'pnl' ] = ( entry_price - current_price ) * quantity
if pos [ 'cost_value' ] > 0 :
pos [ 'pnl_percent' ] = round ( pos [ 'pnl' ] / pos [ 'cost_value' ] * 100 , 2 )
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : positions })
except Exception as e :
logger . error ( f "get_positions failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : []}), 500
@portfolio_bp.route ( '/positions' , methods = [ 'POST' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def add_position ():
2026-01-14 05:29:55 +08:00
"""Add a new manual position for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
market = ( data . get ( 'market' ) or '' ) . strip ()
symbol = _normalize_symbol ( data . get ( 'symbol' ))
name_in = ( data . get ( 'name' ) or '' ) . strip ()
side = ( data . get ( 'side' ) or 'long' ) . strip () . lower ()
quantity = float ( data . get ( 'quantity' ) or 0 )
entry_price = float ( data . get ( 'entry_price' ) or 0 )
entry_time = data . get ( 'entry_time' ) or _now_ts ()
notes = ( data . get ( 'notes' ) or '' ) . strip ()
tags = data . get ( 'tags' ) or []
group_name = ( data . get ( 'group_name' ) or '' ) . strip ()
if not market or not symbol :
return jsonify ({ 'code' : 0 , 'msg' : 'Missing market or symbol' , 'data' : None }), 400
if quantity <= 0 :
return jsonify ({ 'code' : 0 , 'msg' : 'Quantity must be positive' , 'data' : None }), 400
if entry_price <= 0 :
return jsonify ({ 'code' : 0 , 'msg' : 'Entry price must be positive' , 'data' : None }), 400
if side not in ( 'long' , 'short' ):
side = 'long'
# Resolve display name
resolved = resolve_symbol_name ( market , symbol ) or seed_get_symbol_name ( market , symbol )
name = name_in or resolved or symbol
tags_json = json . dumps ( tags if isinstance ( tags , list ) else [], ensure_ascii = False )
with get_db_connection () as db :
cur = db . cursor ()
2026-03-23 23:01:04 +08:00
# Delete any existing positions for this symbol (regardless of side),
# ensuring only one position per symbol per user per group.
cur . execute (
"DELETE FROM qd_manual_positions WHERE user_id = ? AND market = ? AND symbol = ? AND group_name = ?" ,
( user_id , market , symbol , group_name )
)
2026-01-12 22:36:10 +08:00
cur . execute (
"""
INSERT INTO qd_manual_positions
(user_id, market, symbol, name, side, quantity, entry_price, entry_time, notes, tags, group_name, created_at, updated_at)
2026-01-14 05:29:55 +08:00
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
2026-01-12 22:36:10 +08:00
""" ,
2026-01-14 05:29:55 +08:00
( user_id , market , symbol , name , side , quantity , entry_price , entry_time , notes , tags_json , group_name )
2026-01-12 22:36:10 +08:00
)
position_id = cur . lastrowid
db . commit ()
cur . close ()
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : { 'id' : position_id }})
except Exception as e :
logger . error ( f "add_position failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
@portfolio_bp.route ( '/positions/<int:position_id>' , methods = [ 'PUT' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def update_position ( position_id ):
2026-01-14 05:29:55 +08:00
"""Update an existing position for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
updates = []
params = []
if 'name' in data :
updates . append ( 'name = ?' )
params . append (( data . get ( 'name' ) or '' ) . strip ())
if 'quantity' in data :
quantity = float ( data . get ( 'quantity' ) or 0 )
if quantity <= 0 :
return jsonify ({ 'code' : 0 , 'msg' : 'Quantity must be positive' , 'data' : None }), 400
updates . append ( 'quantity = ?' )
params . append ( quantity )
if 'entry_price' in data :
entry_price = float ( data . get ( 'entry_price' ) or 0 )
if entry_price <= 0 :
return jsonify ({ 'code' : 0 , 'msg' : 'Entry price must be positive' , 'data' : None }), 400
updates . append ( 'entry_price = ?' )
params . append ( entry_price )
if 'entry_time' in data :
updates . append ( 'entry_time = ?' )
params . append ( data . get ( 'entry_time' ))
if 'notes' in data :
updates . append ( 'notes = ?' )
params . append (( data . get ( 'notes' ) or '' ) . strip ())
if 'tags' in data :
tags = data . get ( 'tags' ) or []
updates . append ( 'tags = ?' )
params . append ( json . dumps ( tags if isinstance ( tags , list ) else [], ensure_ascii = False ))
if 'group_name' in data :
updates . append ( 'group_name = ?' )
params . append (( data . get ( 'group_name' ) or '' ) . strip ())
if not updates :
return jsonify ({ 'code' : 0 , 'msg' : 'No fields to update' , 'data' : None }), 400
2026-01-14 05:29:55 +08:00
updates . append ( 'updated_at = NOW()' )
2026-01-12 22:36:10 +08:00
params . append ( position_id )
2026-01-14 05:29:55 +08:00
params . append ( user_id )
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
f "UPDATE qd_manual_positions SET { ', ' . join ( updates ) } WHERE id = ? AND user_id = ?" ,
params
)
db . commit ()
cur . close ()
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : None })
except Exception as e :
logger . error ( f "update_position failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
@portfolio_bp.route ( '/positions/<int:position_id>' , methods = [ 'DELETE' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def delete_position ( position_id ):
2026-01-14 05:29:55 +08:00
"""Delete a position for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"DELETE FROM qd_manual_positions WHERE id = ? AND user_id = ?" ,
2026-01-14 05:29:55 +08:00
( position_id , user_id )
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : None })
except Exception as e :
logger . error ( f "delete_position failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
@portfolio_bp.route ( '/summary' , methods = [ 'GET' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def get_portfolio_summary ():
2026-01-14 05:29:55 +08:00
"""Get portfolio summary with total value, PnL, and market distribution for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
# Check if force refresh
2026-01-13 00:21:33 +08:00
force_refresh = request . args . get ( 'refresh' , '' ) . lower () in ( '1' , 'true' , 'yes' )
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
SELECT id, market, symbol, side, quantity, entry_price
FROM qd_manual_positions
WHERE user_id = ?
""" ,
2026-01-14 05:29:55 +08:00
( user_id ,)
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
cur . close ()
if not rows :
return jsonify ({
'code' : 1 ,
'msg' : 'success' ,
'data' : {
'total_cost' : 0 ,
'total_market_value' : 0 ,
'total_pnl' : 0 ,
'total_pnl_percent' : 0 ,
'position_count' : 0 ,
'market_distribution' : []
}
})
2026-01-13 00:21:33 +08:00
# Fetch prices in parallel (with force_refresh support)
2026-01-12 22:36:10 +08:00
price_futures = {}
for row in rows :
market = row . get ( 'market' )
symbol = row . get ( 'symbol' )
key = f " { market } : { symbol } "
if key not in price_futures :
2026-01-13 00:21:33 +08:00
future = executor . submit ( _get_single_price , market , symbol , force_refresh )
2026-01-12 22:36:10 +08:00
price_futures [ key ] = future
price_map = {}
for key , future in price_futures . items ():
try :
result = future . result ( timeout = 10 )
price_map [ key ] = result
except Exception :
pass
# Calculate totals
total_cost = 0
total_market_value = 0
total_pnl = 0
market_values = {} # {market: market_value}
for row in rows :
market = row . get ( 'market' )
symbol = row . get ( 'symbol' )
side = row . get ( 'side' ) or 'long'
quantity = float ( row . get ( 'quantity' ) or 0 )
entry_price = float ( row . get ( 'entry_price' ) or 0 )
key = f " { market } : { symbol } "
price_data = price_map . get ( key , {})
current_price = float ( price_data . get ( 'price' ) or 0 )
cost = entry_price * quantity
market_val = current_price * quantity
if side == 'long' :
pnl = ( current_price - entry_price ) * quantity
else :
pnl = ( entry_price - current_price ) * quantity
total_cost += cost
total_market_value += market_val
total_pnl += pnl
# Market distribution
if market not in market_values :
market_values [ market ] = 0
market_values [ market ] += market_val
total_pnl_percent = round ( total_pnl / total_cost * 100 , 2 ) if total_cost > 0 else 0
# Build market distribution
market_distribution = []
for market , value in market_values . items ():
percent = round ( value / total_market_value * 100 , 2 ) if total_market_value > 0 else 0
market_distribution . append ({
'market' : market ,
'value' : round ( value , 2 ),
'percent' : percent
})
market_distribution . sort ( key = lambda x : x [ 'value' ], reverse = True )
return jsonify ({
'code' : 1 ,
'msg' : 'success' ,
'data' : {
'total_cost' : round ( total_cost , 2 ),
'total_market_value' : round ( total_market_value , 2 ),
'total_pnl' : round ( total_pnl , 2 ),
'total_pnl_percent' : total_pnl_percent ,
'position_count' : len ( rows ),
'market_distribution' : market_distribution
}
})
except Exception as e :
logger . error ( f "get_portfolio_summary failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
# ==================== Monitor CRUD ====================
@portfolio_bp.route ( '/monitors' , methods = [ 'GET' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def get_monitors ():
2026-01-14 05:29:55 +08:00
"""Get all position monitors for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
SELECT id, name, position_ids, monitor_type, config, notification_config,
is_active, last_run_at, next_run_at, last_result, run_count, created_at, updated_at
FROM qd_position_monitors
WHERE user_id = ?
ORDER BY id DESC
""" ,
2026-01-14 05:29:55 +08:00
( user_id ,)
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
cur . close ()
monitors = []
for row in rows :
monitors . append ({
'id' : row . get ( 'id' ),
'name' : row . get ( 'name' ) or '' ,
'position_ids' : _safe_json_loads ( row . get ( 'position_ids' ), []),
'monitor_type' : row . get ( 'monitor_type' ) or 'ai' ,
'config' : _safe_json_loads ( row . get ( 'config' ), {}),
'notification_config' : _safe_json_loads ( row . get ( 'notification_config' ), {}),
'is_active' : bool ( row . get ( 'is_active' )),
2026-03-25 13:27:48 +08:00
'last_run_at' : _serialize_monitor_ts ( row . get ( 'last_run_at' )),
'next_run_at' : _serialize_monitor_ts ( row . get ( 'next_run_at' )),
2026-01-12 22:36:10 +08:00
'last_result' : _safe_json_loads ( row . get ( 'last_result' ), {}),
'run_count' : row . get ( 'run_count' ) or 0 ,
2026-03-25 13:27:48 +08:00
'created_at' : _serialize_monitor_ts ( row . get ( 'created_at' )),
'updated_at' : _serialize_monitor_ts ( row . get ( 'updated_at' ))
2026-01-12 22:36:10 +08:00
})
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : monitors })
except Exception as e :
logger . error ( f "get_monitors failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : []}), 500
@portfolio_bp.route ( '/monitors' , methods = [ 'POST' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def add_monitor ():
2026-01-14 05:29:55 +08:00
"""Add a new position monitor for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
name = ( data . get ( 'name' ) or '' ) . strip ()
position_ids = data . get ( 'position_ids' ) or []
monitor_type = ( data . get ( 'monitor_type' ) or 'ai' ) . strip ()
config = data . get ( 'config' ) or {}
notification_config = data . get ( 'notification_config' ) or {}
is_active = bool ( data . get ( 'is_active' , True ))
if not name :
return jsonify ({ 'code' : 0 , 'msg' : 'Monitor name is required' , 'data' : None }), 400
if monitor_type not in ( 'ai' , 'price_alert' , 'pnl_alert' ):
monitor_type = 'ai'
2026-03-23 23:01:04 +08:00
# Calculate next_run_at based on interval (frontend sends run_interval_minutes)
interval_minutes = int ( config . get ( 'run_interval_minutes' ) or config . get ( 'interval_minutes' ) or 60 )
2026-01-12 22:36:10 +08:00
position_ids_json = json . dumps ( position_ids if isinstance ( position_ids , list ) else [], ensure_ascii = False )
config_json = json . dumps ( config if isinstance ( config , dict ) else {}, ensure_ascii = False )
notification_config_json = json . dumps ( notification_config if isinstance ( notification_config , dict ) else {}, ensure_ascii = False )
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
INSERT INTO qd_position_monitors
(user_id, name, position_ids, monitor_type, config, notification_config, is_active, next_run_at, created_at, updated_at)
2026-01-14 05:29:55 +08:00
VALUES (?, ?, ?, ?, ?, ?, ?, NOW() + INTERVAL ' %s minutes', NOW(), NOW())
2026-01-12 22:36:10 +08:00
""" ,
2026-01-14 05:29:55 +08:00
( user_id , name , position_ids_json , monitor_type , config_json , notification_config_json ,
1 if is_active else 0 , interval_minutes )
2026-01-12 22:36:10 +08:00
)
monitor_id = cur . lastrowid
db . commit ()
cur . close ()
2026-04-06 16:47:36 +07:00
# Run one round in the background immediately after creation: send a notification immediately, and write next_run_at based on the completion time (execute again after the interval)
2026-03-24 19:10:18 +08:00
if is_active and monitor_id :
try :
from app.services.portfolio_monitor import run_single_monitor as _run_single_monitor
def _initial_run ():
try :
_run_single_monitor ( int ( monitor_id ), user_id = int ( user_id ))
except Exception as ex :
logger . error ( f "Initial portfolio monitor run failed # { monitor_id } : { ex } " )
threading . Thread (
target = _initial_run ,
daemon = True ,
name = f "monitor-init- { monitor_id } " ,
) . start ()
except Exception as ex :
logger . error ( f "Failed to schedule initial monitor run # { monitor_id } : { ex } " )
2026-01-12 22:36:10 +08:00
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : { 'id' : monitor_id }})
except Exception as e :
logger . error ( f "add_monitor failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
@portfolio_bp.route ( '/monitors/<int:monitor_id>' , methods = [ 'PUT' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def update_monitor ( monitor_id ):
2026-01-14 05:29:55 +08:00
"""Update an existing monitor for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
updates = []
params = []
if 'name' in data :
updates . append ( 'name = ?' )
params . append (( data . get ( 'name' ) or '' ) . strip ())
if 'position_ids' in data :
position_ids = data . get ( 'position_ids' ) or []
updates . append ( 'position_ids = ?' )
params . append ( json . dumps ( position_ids if isinstance ( position_ids , list ) else [], ensure_ascii = False ))
if 'monitor_type' in data :
updates . append ( 'monitor_type = ?' )
params . append (( data . get ( 'monitor_type' ) or 'ai' ) . strip ())
2026-01-14 05:29:55 +08:00
next_run_interval = None # Will store interval for special handling
2026-01-12 22:36:10 +08:00
if 'config' in data :
config = data . get ( 'config' ) or {}
updates . append ( 'config = ?' )
params . append ( json . dumps ( config if isinstance ( config , dict ) else {}, ensure_ascii = False ))
2026-03-23 23:01:04 +08:00
# Recalculate next_run_at if interval changed
next_run_interval = int ( config . get ( 'run_interval_minutes' ) or config . get ( 'interval_minutes' ) or 60 )
2026-01-12 22:36:10 +08:00
if 'notification_config' in data :
notification_config = data . get ( 'notification_config' ) or {}
updates . append ( 'notification_config = ?' )
params . append ( json . dumps ( notification_config if isinstance ( notification_config , dict ) else {}, ensure_ascii = False ))
if 'is_active' in data :
updates . append ( 'is_active = ?' )
params . append ( 1 if data . get ( 'is_active' ) else 0 )
if not updates :
return jsonify ({ 'code' : 0 , 'msg' : 'No fields to update' , 'data' : None }), 400
2026-01-14 05:29:55 +08:00
# Add next_run_at update if interval was changed
if next_run_interval is not None :
updates . append ( f "next_run_at = NOW() + INTERVAL ' { next_run_interval } minutes'" )
updates . append ( 'updated_at = NOW()' )
2026-01-12 22:36:10 +08:00
params . append ( monitor_id )
2026-01-14 05:29:55 +08:00
params . append ( user_id )
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
f "UPDATE qd_position_monitors SET { ', ' . join ( updates ) } WHERE id = ? AND user_id = ?" ,
params
)
db . commit ()
cur . close ()
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : None })
except Exception as e :
logger . error ( f "update_monitor failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
@portfolio_bp.route ( '/monitors/<int:monitor_id>' , methods = [ 'DELETE' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def delete_monitor ( monitor_id ):
2026-01-14 05:29:55 +08:00
"""Delete a monitor for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"DELETE FROM qd_position_monitors WHERE id = ? AND user_id = ?" ,
2026-01-14 05:29:55 +08:00
( monitor_id , user_id )
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : None })
except Exception as e :
logger . error ( f "delete_monitor failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
@portfolio_bp.route ( '/monitors/<int:monitor_id>/run' , methods = [ 'POST' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def run_monitor_now ( monitor_id ):
2026-01-14 20:42:10 +08:00
"""Manually trigger a monitor to run immediately.
Supports two modes:
- async=true (default): Returns immediately, runs in background, notifies via notification system
- async=false: Waits for completion and returns result (may timeout for large portfolios)
"""
2026-01-12 22:36:10 +08:00
try :
from app.services.portfolio_monitor import run_single_monitor
2026-01-14 20:42:10 +08:00
user_id = g . user_id
# Get parameters from request body
2026-01-12 22:36:10 +08:00
data = request . get_json ( force = True , silent = True ) or {}
language = data . get ( 'language' )
2026-01-14 20:42:10 +08:00
async_mode = data . get ( 'async' , True ) # Default to async mode
2026-01-12 22:36:10 +08:00
2026-01-14 20:42:10 +08:00
# Fallback to Accept-Language header for language
2026-01-12 22:36:10 +08:00
if not language :
accept_lang = request . headers . get ( 'Accept-Language' , '' )
if 'zh' in accept_lang . lower ():
2026-01-14 20:42:10 +08:00
language = 'zh-CN'
2026-01-12 22:36:10 +08:00
else :
language = 'en-US'
2026-01-14 20:42:10 +08:00
if async_mode :
# Async mode: Start background thread and return immediately
import threading
def run_in_background ( mid , lang , uid ):
try :
run_single_monitor ( mid , override_language = lang , user_id = uid )
except Exception as e :
logger . error ( f "Background monitor run failed: { e } " )
thread = threading . Thread (
target = run_in_background ,
args = ( monitor_id , language , user_id ),
daemon = True
)
thread . start ()
return jsonify ({
'code' : 1 ,
'msg' : 'success' ,
'data' : {
'status' : 'running' ,
'message' : 'Monitor is running in background. Results will be sent via notification.'
}
})
else :
# Sync mode: Wait for completion (may timeout)
result = run_single_monitor ( monitor_id , override_language = language , user_id = user_id )
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : result })
2026-01-12 22:36:10 +08:00
except Exception as e :
logger . error ( f "run_monitor_now failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
# ==================== Alerts CRUD ====================
@portfolio_bp.route ( '/alerts' , methods = [ 'GET' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def get_alerts ():
2026-01-14 05:29:55 +08:00
"""Get all position alerts for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
SELECT a.id, a.position_id, a.market, a.symbol, a.alert_type, a.threshold,
a.notification_config, a.is_active, a.is_triggered, a.last_triggered_at,
a.trigger_count, a.repeat_interval, a.notes, a.created_at, a.updated_at,
p.name as position_name, p.side as position_side
FROM qd_position_alerts a
LEFT JOIN qd_manual_positions p ON a.position_id = p.id
WHERE a.user_id = ?
ORDER BY a.id DESC
""" ,
2026-01-14 05:29:55 +08:00
( user_id ,)
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
cur . close ()
alerts = []
for row in rows :
alerts . append ({
'id' : row . get ( 'id' ),
'position_id' : row . get ( 'position_id' ),
'market' : row . get ( 'market' ) or '' ,
'symbol' : row . get ( 'symbol' ) or '' ,
'alert_type' : row . get ( 'alert_type' ) or 'price_above' ,
'threshold' : float ( row . get ( 'threshold' ) or 0 ),
'notification_config' : _safe_json_loads ( row . get ( 'notification_config' ), {}),
'is_active' : bool ( row . get ( 'is_active' )),
'is_triggered' : bool ( row . get ( 'is_triggered' )),
'last_triggered_at' : row . get ( 'last_triggered_at' ),
'trigger_count' : row . get ( 'trigger_count' ) or 0 ,
'repeat_interval' : row . get ( 'repeat_interval' ) or 0 ,
'notes' : row . get ( 'notes' ) or '' ,
'created_at' : row . get ( 'created_at' ),
'updated_at' : row . get ( 'updated_at' ),
'position_name' : row . get ( 'position_name' ) or '' ,
'position_side' : row . get ( 'position_side' ) or 'long'
})
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : alerts })
except Exception as e :
logger . error ( f "get_alerts failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : []}), 500
@portfolio_bp.route ( '/alerts' , methods = [ 'POST' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def add_alert ():
2026-01-14 05:29:55 +08:00
"""Add a new position alert for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
position_id = data . get ( 'position_id' ) # Can be None for symbol-level alerts
market = ( data . get ( 'market' ) or '' ) . strip ()
symbol = _normalize_symbol ( data . get ( 'symbol' ))
alert_type = ( data . get ( 'alert_type' ) or 'price_above' ) . strip ()
threshold = float ( data . get ( 'threshold' ) or 0 )
notification_config = data . get ( 'notification_config' ) or {}
is_active = bool ( data . get ( 'is_active' , True ))
repeat_interval = int ( data . get ( 'repeat_interval' ) or 0 )
notes = ( data . get ( 'notes' ) or '' ) . strip ()
# Validate alert_type
valid_types = ( 'price_above' , 'price_below' , 'pnl_above' , 'pnl_below' )
if alert_type not in valid_types :
return jsonify ({ 'code' : 0 , 'msg' : f 'Invalid alert_type. Must be one of: { valid_types } ' , 'data' : None }), 400
# If position_id provided, get market/symbol from position
if position_id :
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"SELECT market, symbol FROM qd_manual_positions WHERE id = ? AND user_id = ?" ,
2026-01-14 05:29:55 +08:00
( position_id , user_id )
2026-01-12 22:36:10 +08:00
)
pos = cur . fetchone ()
cur . close ()
if pos :
market = pos . get ( 'market' ) or market
symbol = pos . get ( 'symbol' ) or symbol
if not market or not symbol :
return jsonify ({ 'code' : 0 , 'msg' : 'Market and symbol are required' , 'data' : None }), 400
if threshold <= 0 and alert_type . startswith ( 'price_' ):
return jsonify ({ 'code' : 0 , 'msg' : 'Threshold must be positive for price alerts' , 'data' : None }), 400
notification_config_json = json . dumps ( notification_config if isinstance ( notification_config , dict ) else {}, ensure_ascii = False )
with get_db_connection () as db :
cur = db . cursor ()
# Check if alert already exists for this position (unique constraint)
existing_alert_id = None
if position_id :
cur . execute (
"SELECT id FROM qd_position_alerts WHERE position_id = ? AND user_id = ?" ,
2026-01-14 05:29:55 +08:00
( position_id , user_id )
2026-01-12 22:36:10 +08:00
)
existing = cur . fetchone ()
if existing :
existing_alert_id = existing . get ( 'id' )
if existing_alert_id :
# Update existing alert instead of creating a new one
cur . execute (
"""
UPDATE qd_position_alerts
SET alert_type = ?, threshold = ?, notification_config = ?,
2026-01-14 05:29:55 +08:00
is_active = ?, is_triggered = 0, repeat_interval = ?, notes = ?, updated_at = NOW()
2026-01-12 22:36:10 +08:00
WHERE id = ?
""" ,
( alert_type , threshold , notification_config_json ,
2026-01-14 05:29:55 +08:00
1 if is_active else 0 , repeat_interval , notes , existing_alert_id )
2026-01-12 22:36:10 +08:00
)
alert_id = existing_alert_id
else :
# Create new alert
cur . execute (
"""
INSERT INTO qd_position_alerts
(user_id, position_id, market, symbol, alert_type, threshold, notification_config,
is_active, repeat_interval, notes, created_at, updated_at)
2026-01-14 05:29:55 +08:00
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
2026-01-12 22:36:10 +08:00
""" ,
2026-01-14 05:29:55 +08:00
( user_id , position_id , market , symbol , alert_type , threshold , notification_config_json ,
1 if is_active else 0 , repeat_interval , notes )
2026-01-12 22:36:10 +08:00
)
alert_id = cur . lastrowid
db . commit ()
cur . close ()
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : { 'id' : alert_id }})
except Exception as e :
logger . error ( f "add_alert failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
@portfolio_bp.route ( '/alerts/<int:alert_id>' , methods = [ 'PUT' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def update_alert ( alert_id ):
2026-01-14 05:29:55 +08:00
"""Update an existing alert for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
updates = []
params = []
if 'alert_type' in data :
updates . append ( 'alert_type = ?' )
params . append (( data . get ( 'alert_type' ) or 'price_above' ) . strip ())
if 'threshold' in data :
updates . append ( 'threshold = ?' )
params . append ( float ( data . get ( 'threshold' ) or 0 ))
if 'notification_config' in data :
notification_config = data . get ( 'notification_config' ) or {}
updates . append ( 'notification_config = ?' )
params . append ( json . dumps ( notification_config if isinstance ( notification_config , dict ) else {}, ensure_ascii = False ))
if 'is_active' in data :
updates . append ( 'is_active = ?' )
params . append ( 1 if data . get ( 'is_active' ) else 0 )
# Reset triggered state when re-activating
if data . get ( 'is_active' ):
updates . append ( 'is_triggered = ?' )
params . append ( 0 )
if 'repeat_interval' in data :
updates . append ( 'repeat_interval = ?' )
params . append ( int ( data . get ( 'repeat_interval' ) or 0 ))
if 'notes' in data :
updates . append ( 'notes = ?' )
params . append (( data . get ( 'notes' ) or '' ) . strip ())
if not updates :
return jsonify ({ 'code' : 0 , 'msg' : 'No fields to update' , 'data' : None }), 400
2026-01-14 05:29:55 +08:00
updates . append ( 'updated_at = NOW()' )
2026-01-12 22:36:10 +08:00
params . append ( alert_id )
2026-01-14 05:29:55 +08:00
params . append ( user_id )
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
f "UPDATE qd_position_alerts SET { ', ' . join ( updates ) } WHERE id = ? AND user_id = ?" ,
params
)
db . commit ()
cur . close ()
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : None })
except Exception as e :
logger . error ( f "update_alert failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
@portfolio_bp.route ( '/alerts/<int:alert_id>' , methods = [ 'DELETE' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def delete_alert ( alert_id ):
2026-01-14 05:29:55 +08:00
"""Delete an alert for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"DELETE FROM qd_position_alerts WHERE id = ? AND user_id = ?" ,
2026-01-14 05:29:55 +08:00
( alert_id , user_id )
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : None })
except Exception as e :
logger . error ( f "delete_alert failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
# ==================== Groups ====================
@portfolio_bp.route ( '/groups' , methods = [ 'GET' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def get_groups ():
2026-01-14 05:29:55 +08:00
"""Get list of all groups with position counts for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
"""
SELECT group_name, COUNT(*) as count
FROM qd_manual_positions
WHERE user_id = ? AND group_name != ''
GROUP BY group_name
ORDER BY group_name
""" ,
2026-01-14 05:29:55 +08:00
( user_id ,)
2026-01-12 22:36:10 +08:00
)
rows = cur . fetchall () or []
# Also get count of ungrouped
cur . execute (
"SELECT COUNT(*) as count FROM qd_manual_positions WHERE user_id = ? AND (group_name IS NULL OR group_name = '')" ,
2026-01-14 05:29:55 +08:00
( user_id ,)
2026-01-12 22:36:10 +08:00
)
ungrouped = cur . fetchone ()
cur . close ()
groups = []
for row in rows :
groups . append ({
'name' : row . get ( 'group_name' ),
'count' : row . get ( 'count' ) or 0
})
# Add ungrouped count
ungrouped_count = ( ungrouped . get ( 'count' ) or 0 ) if ungrouped else 0
return jsonify ({
'code' : 1 ,
'msg' : 'success' ,
'data' : {
'groups' : groups ,
'ungrouped_count' : ungrouped_count
}
})
except Exception as e :
logger . error ( f "get_groups failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500
@portfolio_bp.route ( '/groups/rename' , methods = [ 'POST' ])
2026-01-14 05:29:55 +08:00
@login_required
2026-01-12 22:36:10 +08:00
def rename_group ():
2026-01-14 05:29:55 +08:00
"""Rename a group for the current user."""
2026-01-12 22:36:10 +08:00
try :
2026-01-14 05:29:55 +08:00
user_id = g . user_id
2026-01-12 22:36:10 +08:00
data = request . get_json () or {}
old_name = ( data . get ( 'old_name' ) or '' ) . strip ()
new_name = ( data . get ( 'new_name' ) or '' ) . strip ()
if not old_name :
return jsonify ({ 'code' : 0 , 'msg' : 'old_name is required' , 'data' : None }), 400
with get_db_connection () as db :
cur = db . cursor ()
cur . execute (
2026-01-14 05:29:55 +08:00
"UPDATE qd_manual_positions SET group_name = ?, updated_at = NOW() WHERE user_id = ? AND group_name = ?" ,
( new_name , user_id , old_name )
2026-01-12 22:36:10 +08:00
)
db . commit ()
cur . close ()
return jsonify ({ 'code' : 1 , 'msg' : 'success' , 'data' : None })
except Exception as e :
logger . error ( f "rename_group failed: { str ( e ) } " )
logger . error ( traceback . format_exc ())
return jsonify ({ 'code' : 0 , 'msg' : str ( e ), 'data' : None }), 500