DhanHQ 3.0.1 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +47 -1
- data/CHANGELOG.md +98 -0
- data/README.md +392 -6
- data/docs/AUTHENTICATION.md +3 -5
- data/docs/CONFIGURATION.md +3 -2
- data/docs/CONSTANTS_REFERENCE.md +8 -6
- data/docs/ENDPOINTS_AND_SANDBOX.md +1 -0
- data/docs/LIVE_ORDER_UPDATES.md +5 -10
- data/docs/RAILS_INTEGRATION.md +1 -1
- data/docs/RELEASE_GUIDE.md +13 -2
- data/docs/STANDALONE_RUBY_WEBSOCKET_INTEGRATION.md +2 -2
- data/docs/WEBSOCKET_INTEGRATION.md +13 -20
- data/docs/WEBSOCKET_PROTOCOL.md +7 -3
- data/exe/dhanhq-mcp +7 -0
- data/lib/DhanHQ/agent/key_coercion.rb +36 -0
- data/lib/DhanHQ/agent/tool.rb +37 -0
- data/lib/DhanHQ/agent/tool_catalogue.rb +180 -0
- data/lib/DhanHQ/agent/tool_handlers.rb +116 -0
- data/lib/DhanHQ/agent/tool_registry.rb +17 -212
- data/lib/DhanHQ/agent/tool_schemas.rb +160 -0
- data/lib/DhanHQ/client.rb +58 -2
- data/lib/DhanHQ/concerns/order_audit.rb +74 -1
- data/lib/DhanHQ/configuration.rb +70 -1
- data/lib/DhanHQ/constants.rb +104 -2
- data/lib/DhanHQ/contracts/expired_options_data_contract.rb +1 -9
- data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/global_stocks_estimator_contract.rb +28 -0
- data/lib/DhanHQ/contracts/global_stocks_modify_order_contract.rb +26 -0
- data/lib/DhanHQ/contracts/global_stocks_order_contract.rb +33 -0
- data/lib/DhanHQ/contracts/global_stocks_place_order_contract.rb +75 -0
- data/lib/DhanHQ/contracts/historical_data_contract.rb +1 -21
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/multi_order_contract.rb +74 -0
- data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/trade_history_contract.rb +1 -8
- data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
- data/lib/DhanHQ/dry_run/ledger.rb +71 -0
- data/lib/DhanHQ/dry_run/simulator.rb +140 -0
- data/lib/DhanHQ/helpers/attribute_helper.rb +23 -0
- data/lib/DhanHQ/mcp/server.rb +172 -9
- data/lib/DhanHQ/models/alert_order.rb +5 -2
- data/lib/DhanHQ/models/global_stocks/funds.rb +56 -0
- data/lib/DhanHQ/models/global_stocks/holding.rb +101 -0
- data/lib/DhanHQ/models/global_stocks/margin.rb +54 -0
- data/lib/DhanHQ/models/global_stocks/market_status.rb +63 -0
- data/lib/DhanHQ/models/global_stocks/order.rb +189 -0
- data/lib/DhanHQ/models/global_stocks/order_estimate.rb +61 -0
- data/lib/DhanHQ/models/global_stocks/trade.rb +74 -0
- data/lib/DhanHQ/models/instrument.rb +44 -14
- data/lib/DhanHQ/models/margin.rb +5 -1
- data/lib/DhanHQ/models/multi_order.rb +130 -0
- data/lib/DhanHQ/rate_limiter.rb +5 -3
- data/lib/DhanHQ/resources/alert_orders.rb +1 -0
- data/lib/DhanHQ/resources/forever_orders.rb +1 -0
- data/lib/DhanHQ/resources/global_stocks/funds.rb +25 -0
- data/lib/DhanHQ/resources/global_stocks/holdings.rb +22 -0
- data/lib/DhanHQ/resources/global_stocks/margin_calculator.rb +70 -0
- data/lib/DhanHQ/resources/global_stocks/market_status.rb +22 -0
- data/lib/DhanHQ/resources/global_stocks/orders.rb +112 -0
- data/lib/DhanHQ/resources/global_stocks/trades.rb +31 -0
- data/lib/DhanHQ/resources/iceberg_orders.rb +1 -0
- data/lib/DhanHQ/resources/multi_orders.rb +58 -0
- data/lib/DhanHQ/resources/orders.rb +2 -0
- data/lib/DhanHQ/resources/pnl_exit.rb +1 -0
- data/lib/DhanHQ/resources/super_orders.rb +1 -0
- data/lib/DhanHQ/resources/twap_orders.rb +1 -0
- data/lib/DhanHQ/risk/checks/concentration.rb +37 -0
- data/lib/DhanHQ/risk/checks/max_loss.rb +24 -0
- data/lib/DhanHQ/risk/checks/position_limits.rb +24 -0
- data/lib/DhanHQ/risk/pipeline.rb +8 -1
- data/lib/DhanHQ/skills/base.rb +54 -3
- data/lib/DhanHQ/skills/builtin/bear_call_spread.rb +87 -0
- data/lib/DhanHQ/skills/builtin/bull_put_spread.rb +87 -0
- data/lib/DhanHQ/skills/builtin/buy_atm_call.rb +10 -13
- data/lib/DhanHQ/skills/builtin/covered_call.rb +85 -0
- data/lib/DhanHQ/skills/builtin/iron_condor.rb +15 -19
- data/lib/DhanHQ/skills/builtin/market_data_summarizer.rb +195 -0
- data/lib/DhanHQ/skills/builtin/protective_put.rb +90 -0
- data/lib/DhanHQ/skills/builtin/square_off_all.rb +5 -8
- data/lib/DhanHQ/skills/builtin/square_off_position.rb +8 -6
- data/lib/DhanHQ/skills/builtin/straddle.rb +88 -0
- data/lib/DhanHQ/skills/builtin/strangle.rb +13 -13
- data/lib/DhanHQ/version.rb +1 -1
- data/lib/DhanHQ/write_paths.rb +57 -0
- data/lib/DhanHQ/ws/client.rb +117 -2
- data/lib/DhanHQ/ws/connection.rb +40 -11
- data/lib/DhanHQ/ws/sub_state.rb +14 -0
- data/lib/dhan_hq.rb +47 -0
- data/lib/dhanhq/analysis/options_buying_advisor.rb +11 -10
- metadata +41 -15
- data/.rspec +0 -3
- data/.rubocop.yml +0 -48
- data/.rubocop_todo.yml +0 -217
- data/AGENTS.md +0 -23
- data/CODE_OF_CONDUCT.md +0 -132
- data/Rakefile +0 -14
- data/TAGS +0 -10
- data/core +0 -0
- data/diagram.html +0 -184
- data/skills/dhanhq-ruby/SKILL.md +0 -74
- data/skills/dhanhq-ruby/references/market_data.md +0 -3
- data/skills/dhanhq-ruby/references/orders.md +0 -7
- data/watchlist.csv +0 -3
|
@@ -14,6 +14,10 @@ module DhanHQ
|
|
|
14
14
|
# )
|
|
15
15
|
#
|
|
16
16
|
class SquareOffPosition < Base
|
|
17
|
+
risk "destructive_write"
|
|
18
|
+
scope "orders:write"
|
|
19
|
+
description "Exit a specific open position by symbol and exchange segment."
|
|
20
|
+
|
|
17
21
|
param :symbol, type: :string, required: true
|
|
18
22
|
param :exchange_segment, type: :string, required: true
|
|
19
23
|
|
|
@@ -23,17 +27,15 @@ module DhanHQ
|
|
|
23
27
|
def find_position(ctx)
|
|
24
28
|
positions = DhanHQ::Models::Position.all
|
|
25
29
|
target = positions.find do |p|
|
|
26
|
-
|
|
27
|
-
sym = p[:trading_symbol] || p["tradingSymbol"] || p[:symbol] || p["symbol"]
|
|
28
|
-
seg.to_s == ctx[:exchange_segment].to_s && sym.to_s.upcase == ctx[:symbol].to_s.upcase
|
|
30
|
+
p.exchange_segment.to_s == ctx[:exchange_segment].to_s && p.trading_symbol.to_s.upcase == ctx[:symbol].to_s.upcase
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
raise ArgumentError, "No open position found for #{ctx[:symbol]} on #{ctx[:exchange_segment]}" unless target
|
|
32
34
|
|
|
33
35
|
ctx[:position] = target
|
|
34
|
-
ctx[:security_id] = target
|
|
35
|
-
ctx[:trading_symbol] = target
|
|
36
|
-
ctx[:net_quantity] =
|
|
36
|
+
ctx[:security_id] = target.security_id
|
|
37
|
+
ctx[:trading_symbol] = target.trading_symbol
|
|
38
|
+
ctx[:net_quantity] = target.net_qty.to_i
|
|
37
39
|
ctx
|
|
38
40
|
end
|
|
39
41
|
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
module Builtin
|
|
6
|
+
# Skill to build a long straddle (buy ATM call + buy ATM put).
|
|
7
|
+
#
|
|
8
|
+
# Steps: find instrument → spot price → option chain →
|
|
9
|
+
# select ATM strikes → build intent.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# result = DhanHQ::Skills::Registry.call("straddle",
|
|
13
|
+
# symbol: "NIFTY",
|
|
14
|
+
# expiry: "2026-01-30",
|
|
15
|
+
# quantity: 25
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
class Straddle < Base
|
|
19
|
+
risk "trade_adjacent_read"
|
|
20
|
+
scope "orders:read"
|
|
21
|
+
description "Build a long straddle: buy ATM call + buy ATM put at the same strike."
|
|
22
|
+
|
|
23
|
+
param :symbol, type: :string, required: true
|
|
24
|
+
param :expiry, type: :string, required: true
|
|
25
|
+
param :quantity, type: :integer, default: 25
|
|
26
|
+
param :stop_loss, type: :number, default: 300
|
|
27
|
+
param :target, type: :number, default: 600
|
|
28
|
+
|
|
29
|
+
step :find_instrument, priority: 1
|
|
30
|
+
step :get_spot_price, priority: 2
|
|
31
|
+
step :get_option_chain, priority: 3
|
|
32
|
+
step :select_atm_strikes, priority: 4
|
|
33
|
+
step :build_intent, priority: 5
|
|
34
|
+
|
|
35
|
+
def find_instrument(ctx)
|
|
36
|
+
ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, ctx[:symbol])
|
|
37
|
+
ctx
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_spot_price(ctx)
|
|
41
|
+
ctx[:spot_price] = ctx[:instrument].ltp
|
|
42
|
+
ctx
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def get_option_chain(ctx)
|
|
46
|
+
ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
|
|
47
|
+
ctx
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def select_atm_strikes(ctx)
|
|
51
|
+
spot = ctx[:spot_price].to_f
|
|
52
|
+
chain = ctx[:chain]
|
|
53
|
+
|
|
54
|
+
atm = nearest_strike(chain, spot)
|
|
55
|
+
raise ArgumentError, "Could not find ATM strike" unless atm
|
|
56
|
+
|
|
57
|
+
ctx[:atm_strike] = atm[:strike]
|
|
58
|
+
ctx[:ce_security_id] = leg_security_id(atm, "CE")
|
|
59
|
+
ctx[:pe_security_id] = leg_security_id(atm, "PE")
|
|
60
|
+
ctx[:ce_premium] = leg_premium(atm, "CE")
|
|
61
|
+
ctx[:pe_premium] = leg_premium(atm, "PE")
|
|
62
|
+
ctx[:total_premium] = ctx[:ce_premium].to_f + ctx[:pe_premium].to_f
|
|
63
|
+
ctx
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def build_intent(ctx)
|
|
67
|
+
ctx[:intent] = {
|
|
68
|
+
trade_type: "STRADDLE",
|
|
69
|
+
symbol: ctx[:symbol],
|
|
70
|
+
expiry: ctx[:expiry],
|
|
71
|
+
quantity: ctx[:quantity],
|
|
72
|
+
legs: [
|
|
73
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: ctx[:atm_strike], security_id: ctx[:ce_security_id], premium: ctx[:ce_premium] },
|
|
74
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: ctx[:atm_strike], security_id: ctx[:pe_security_id], premium: ctx[:pe_premium] }
|
|
75
|
+
],
|
|
76
|
+
total_premium: ctx[:total_premium],
|
|
77
|
+
break_even_upside: ctx[:atm_strike].to_f + ctx[:total_premium],
|
|
78
|
+
break_even_downside: ctx[:atm_strike].to_f - ctx[:total_premium],
|
|
79
|
+
stop_loss: ctx[:stop_loss],
|
|
80
|
+
target: ctx[:target],
|
|
81
|
+
note: "Long straddle prepared. Await human confirmation."
|
|
82
|
+
}
|
|
83
|
+
ctx
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -16,6 +16,10 @@ module DhanHQ
|
|
|
16
16
|
# )
|
|
17
17
|
#
|
|
18
18
|
class Strangle < Base
|
|
19
|
+
risk "trade_adjacent_read"
|
|
20
|
+
scope "orders:read"
|
|
21
|
+
description "Build a long strangle: buy OTM call + buy OTM put around the current spot price."
|
|
22
|
+
|
|
19
23
|
param :symbol, type: :string, required: true
|
|
20
24
|
param :expiry, type: :string, required: true
|
|
21
25
|
param :quantity, type: :integer, default: 50
|
|
@@ -35,8 +39,7 @@ module DhanHQ
|
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
def get_spot_price(ctx)
|
|
38
|
-
|
|
39
|
-
ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
|
|
42
|
+
ctx[:spot_price] = ctx[:instrument].ltp
|
|
40
43
|
ctx
|
|
41
44
|
end
|
|
42
45
|
|
|
@@ -50,24 +53,21 @@ module DhanHQ
|
|
|
50
53
|
chain = ctx[:chain]
|
|
51
54
|
offset = ctx[:offset_pct] / 100.0
|
|
52
55
|
|
|
53
|
-
ce_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "CE" }
|
|
54
|
-
pe_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "PE" }
|
|
55
|
-
|
|
56
56
|
ce_strike = spot * (1 + offset)
|
|
57
57
|
pe_strike = spot * (1 - offset)
|
|
58
58
|
|
|
59
|
-
long_ce =
|
|
60
|
-
long_pe =
|
|
59
|
+
long_ce = nearest_strike(chain, ce_strike)
|
|
60
|
+
long_pe = nearest_strike(chain, pe_strike)
|
|
61
61
|
|
|
62
62
|
raise ArgumentError, "Could not find suitable CE strike near #{ce_strike}" unless long_ce
|
|
63
63
|
raise ArgumentError, "Could not find suitable PE strike near #{pe_strike}" unless long_pe
|
|
64
64
|
|
|
65
|
-
ctx[:ce_strike] = long_ce[:strike]
|
|
66
|
-
ctx[:pe_strike] = long_pe[:strike]
|
|
67
|
-
ctx[:ce_security_id] = long_ce
|
|
68
|
-
ctx[:pe_security_id] = long_pe
|
|
69
|
-
ctx[:ce_premium] = long_ce
|
|
70
|
-
ctx[:pe_premium] = long_pe
|
|
65
|
+
ctx[:ce_strike] = long_ce[:strike]
|
|
66
|
+
ctx[:pe_strike] = long_pe[:strike]
|
|
67
|
+
ctx[:ce_security_id] = leg_security_id(long_ce, "CE")
|
|
68
|
+
ctx[:pe_security_id] = leg_security_id(long_pe, "PE")
|
|
69
|
+
ctx[:ce_premium] = leg_premium(long_ce, "CE")
|
|
70
|
+
ctx[:pe_premium] = leg_premium(long_pe, "PE")
|
|
71
71
|
ctx
|
|
72
72
|
end
|
|
73
73
|
|
data/lib/DhanHQ/version.rb
CHANGED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
# Classifies request paths by whether they change account state.
|
|
5
|
+
#
|
|
6
|
+
# The distinction that matters on this API is **mutating vs read-only**, not GET vs
|
|
7
|
+
# POST: the option chain, market feed, historical charts and the margin calculators
|
|
8
|
+
# are all read-only POSTs. Both dry-run simulation and non-idempotent retry gating
|
|
9
|
+
# need that answer, so it lives here rather than in {DhanHQ::Client}.
|
|
10
|
+
module WritePaths
|
|
11
|
+
# HTTP methods that can change server-side state.
|
|
12
|
+
WRITE_METHODS = %i[post put patch delete].freeze
|
|
13
|
+
|
|
14
|
+
# Path of the basket order endpoint, which answers with one result per leg
|
|
15
|
+
# rather than a single order id.
|
|
16
|
+
MULTI_ORDER_PATH = "/v2/alerts/multi/orders"
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# True when this request changes real account state.
|
|
21
|
+
#
|
|
22
|
+
# @param method [Symbol] HTTP method
|
|
23
|
+
# @param path [String, nil] Request path
|
|
24
|
+
# @return [Boolean]
|
|
25
|
+
def mutating?(method, path)
|
|
26
|
+
return false unless WRITE_METHODS.include?(method)
|
|
27
|
+
|
|
28
|
+
match?(path, DhanHQ::Constants::MUTATING_PATH_PREFIXES)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# True when this request places one or more orders.
|
|
32
|
+
#
|
|
33
|
+
# @param method [Symbol] HTTP method
|
|
34
|
+
# @param path [String, nil] Request path
|
|
35
|
+
# @return [Boolean]
|
|
36
|
+
def order_placement?(method, path)
|
|
37
|
+
return false unless method == :post
|
|
38
|
+
|
|
39
|
+
match?(path, DhanHQ::Constants::ORDER_PLACEMENT_PATH_PREFIXES)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# True for the basket order endpoint.
|
|
43
|
+
#
|
|
44
|
+
# @param path [String, nil] Request path
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def multi_order?(path)
|
|
47
|
+
path.to_s.start_with?(MULTI_ORDER_PATH)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def match?(path, prefixes)
|
|
51
|
+
return false if path.nil?
|
|
52
|
+
|
|
53
|
+
prefixes.any? { |prefix| path.start_with?(prefix) }
|
|
54
|
+
end
|
|
55
|
+
private_class_method :match?
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/DhanHQ/ws/client.rb
CHANGED
|
@@ -26,6 +26,9 @@ module DhanHQ
|
|
|
26
26
|
@state = SubState.new
|
|
27
27
|
@callbacks = Concurrent::Map.new { |h, k| h[k] = [] }
|
|
28
28
|
@started = Concurrent::AtomicBoolean.new(false)
|
|
29
|
+
@last_message_at = Concurrent::AtomicReference.new(nil)
|
|
30
|
+
@connected_at = Concurrent::AtomicReference.new(nil)
|
|
31
|
+
@reconnects = Concurrent::AtomicFixnum.new(0)
|
|
29
32
|
|
|
30
33
|
token = DhanHQ.configuration.resolved_access_token
|
|
31
34
|
raise DhanHQ::AuthenticationError, "Missing access token" if token.nil? || token.empty?
|
|
@@ -43,7 +46,8 @@ module DhanHQ
|
|
|
43
46
|
return self if @started.true?
|
|
44
47
|
|
|
45
48
|
@started.make_true
|
|
46
|
-
@conn = Connection.new(url: @url, mode: @mode, bus: @bus, state: @state
|
|
49
|
+
@conn = Connection.new(url: @url, mode: @mode, bus: @bus, state: @state,
|
|
50
|
+
on_event: method(:handle_connection_event)) do |binary|
|
|
47
51
|
tick = Decoder.decode(binary)
|
|
48
52
|
emit(:tick, tick) if tick
|
|
49
53
|
end
|
|
@@ -90,6 +94,80 @@ module DhanHQ
|
|
|
90
94
|
@conn&.open? || false
|
|
91
95
|
end
|
|
92
96
|
|
|
97
|
+
# Timestamp of the most recently received frame.
|
|
98
|
+
#
|
|
99
|
+
# A feed can be "connected" and still be dead — the socket stays open while
|
|
100
|
+
# the server has stopped publishing. Long-running daemons should watch this
|
|
101
|
+
# rather than {#connected?} alone.
|
|
102
|
+
#
|
|
103
|
+
# @return [Time, nil] nil until the first frame arrives.
|
|
104
|
+
def last_message_at
|
|
105
|
+
@last_message_at.get
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Seconds since the last frame arrived.
|
|
109
|
+
#
|
|
110
|
+
# @return [Float, nil] nil until the first frame arrives.
|
|
111
|
+
def seconds_since_last_message
|
|
112
|
+
stamp = last_message_at
|
|
113
|
+
return nil unless stamp
|
|
114
|
+
|
|
115
|
+
Time.now - stamp
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Whether the feed looks alive: connected and delivering frames recently.
|
|
119
|
+
#
|
|
120
|
+
# @param stale_after [Numeric] Seconds of silence after which the feed is
|
|
121
|
+
# considered stale. The server pings roughly every 10 seconds, so the
|
|
122
|
+
# default allows several missed intervals.
|
|
123
|
+
# @return [Boolean]
|
|
124
|
+
def healthy?(stale_after: 45)
|
|
125
|
+
return false unless connected?
|
|
126
|
+
|
|
127
|
+
idle = seconds_since_last_message
|
|
128
|
+
idle.nil? || idle <= stale_after
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Timestamp of the current connection's establishment.
|
|
132
|
+
#
|
|
133
|
+
# @return [Time, nil]
|
|
134
|
+
def connected_at
|
|
135
|
+
@connected_at.get
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Number of times the connection has been re-established since {#start}.
|
|
139
|
+
#
|
|
140
|
+
# @return [Integer]
|
|
141
|
+
def reconnect_count
|
|
142
|
+
@reconnects.value
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Instruments currently subscribed, as `"SEGMENT:SECURITY_ID"` strings.
|
|
146
|
+
#
|
|
147
|
+
# Survives reconnects: the connection replays this set on every new session.
|
|
148
|
+
#
|
|
149
|
+
# @return [Array<String>]
|
|
150
|
+
def subscriptions
|
|
151
|
+
@state.snapshot
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Point-in-time health snapshot, suitable for a monitoring endpoint or log line.
|
|
155
|
+
#
|
|
156
|
+
# @return [Hash]
|
|
157
|
+
def health
|
|
158
|
+
{
|
|
159
|
+
mode: @mode,
|
|
160
|
+
started: @started.true?,
|
|
161
|
+
connected: connected?,
|
|
162
|
+
healthy: healthy?,
|
|
163
|
+
connected_at: connected_at,
|
|
164
|
+
last_message_at: last_message_at,
|
|
165
|
+
seconds_since_last_message: seconds_since_last_message,
|
|
166
|
+
reconnect_count: reconnect_count,
|
|
167
|
+
subscription_count: subscriptions.size
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
93
171
|
# Subscribes to updates for a single instrument.
|
|
94
172
|
#
|
|
95
173
|
# @param segment [String, Symbol, Integer]
|
|
@@ -154,9 +232,27 @@ module DhanHQ
|
|
|
154
232
|
|
|
155
233
|
# Registers a callback for a given event.
|
|
156
234
|
#
|
|
157
|
-
#
|
|
235
|
+
# Supported events:
|
|
236
|
+
#
|
|
237
|
+
# - +:tick+ — a decoded market data packet.
|
|
238
|
+
# - +:open+ — the socket connected. Payload: `{ resubscribed: [...] }`.
|
|
239
|
+
# - +:reconnect+ — the socket re-connected after a drop. Payload:
|
|
240
|
+
# `{ attempt: Integer, resubscribed: [...] }`. Subscriptions are replayed
|
|
241
|
+
# automatically before this fires; use it to re-seed derived state such as
|
|
242
|
+
# candle builders or to alert.
|
|
243
|
+
# - +:close+ — the socket closed. Payload:
|
|
244
|
+
# `{ code:, reason:, reconnecting: Boolean }`.
|
|
245
|
+
# - +:error+ — a socket-level error. Payload: the error message.
|
|
246
|
+
#
|
|
247
|
+
# @param event [Symbol] Event name.
|
|
158
248
|
# @yieldparam payload [Object] Event payload.
|
|
159
249
|
# @return [DhanHQ::WS::Client] self.
|
|
250
|
+
#
|
|
251
|
+
# @example React to a reconnect
|
|
252
|
+
# client.on(:reconnect) do |info|
|
|
253
|
+
# logger.warn "feed reconnected (attempt #{info[:attempt]}), " \
|
|
254
|
+
# "#{info[:resubscribed].size} instruments restored"
|
|
255
|
+
# end
|
|
160
256
|
def on(event, &blk)
|
|
161
257
|
@callbacks[event] << blk
|
|
162
258
|
self
|
|
@@ -164,6 +260,25 @@ module DhanHQ
|
|
|
164
260
|
|
|
165
261
|
private
|
|
166
262
|
|
|
263
|
+
# Bridges connection-level lifecycle events onto the client's callback bus and
|
|
264
|
+
# updates the health counters. +:message+ is internal bookkeeping only — the
|
|
265
|
+
# decoded payload reaches users as +:tick+.
|
|
266
|
+
def handle_connection_event(event, payload)
|
|
267
|
+
case event
|
|
268
|
+
when :message
|
|
269
|
+
@last_message_at.set(Time.now)
|
|
270
|
+
return
|
|
271
|
+
when :open
|
|
272
|
+
@connected_at.set(Time.now)
|
|
273
|
+
when :reconnect
|
|
274
|
+
@reconnects.increment
|
|
275
|
+
when :close
|
|
276
|
+
@connected_at.set(nil)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
emit(event, payload)
|
|
280
|
+
end
|
|
281
|
+
|
|
167
282
|
def prune(hash) = { ExchangeSegment: hash[:ExchangeSegment], SecurityId: hash[:SecurityId] }
|
|
168
283
|
|
|
169
284
|
def emit(event, payload)
|
data/lib/DhanHQ/ws/connection.rb
CHANGED
|
@@ -23,13 +23,18 @@ module DhanHQ
|
|
|
23
23
|
# @param bus [DhanHQ::WS::CmdBus] Command queue feeding subscription
|
|
24
24
|
# changes.
|
|
25
25
|
# @param state [DhanHQ::WS::SubState] Tracks subscription status.
|
|
26
|
+
# @param on_event [Proc, nil] Optional callable invoked as
|
|
27
|
+
# +on_event.call(event, payload)+ for lifecycle events (+:open+, +:close+,
|
|
28
|
+
# +:error+, +:reconnect+). Lets the owning client surface connection state
|
|
29
|
+
# to user callbacks.
|
|
26
30
|
# @yield [binary]
|
|
27
31
|
# @yieldparam binary [String] Raw binary frame received from the socket.
|
|
28
|
-
def initialize(url:, mode:, bus:, state:, &on_binary)
|
|
32
|
+
def initialize(url:, mode:, bus:, state:, on_event: nil, &on_binary)
|
|
29
33
|
@url = url
|
|
30
34
|
@mode = mode
|
|
31
35
|
@bus = bus
|
|
32
36
|
@state = state
|
|
37
|
+
@on_event = on_event
|
|
33
38
|
@on_binary = on_binary
|
|
34
39
|
@stop = false
|
|
35
40
|
@stopping = false
|
|
@@ -90,11 +95,41 @@ module DhanHQ
|
|
|
90
95
|
|
|
91
96
|
private
|
|
92
97
|
|
|
98
|
+
# Notifies the owning client of a lifecycle event. Never lets a user callback
|
|
99
|
+
# take down the connection thread.
|
|
100
|
+
def notify(event, payload = nil)
|
|
101
|
+
@on_event&.call(event, payload)
|
|
102
|
+
rescue StandardError => e
|
|
103
|
+
DhanHQ.logger&.error("[DhanHQ::WS] on_event(#{event}) raised #{e.class}: #{e.message}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Restores subscriptions and starts the command drain for a freshly opened
|
|
107
|
+
# socket, then reports the transition.
|
|
108
|
+
#
|
|
109
|
+
# @param session [Integer] 1-based index of this connection attempt. Anything
|
|
110
|
+
# past the first is a reconnect.
|
|
111
|
+
def handle_open(session)
|
|
112
|
+
DhanHQ.logger&.info("[DhanHQ::WS] open")
|
|
113
|
+
|
|
114
|
+
# The server keeps no subscription state across connections, so the desired set
|
|
115
|
+
# is replayed on every new session. See SubState#resubscribe_payload.
|
|
116
|
+
snapshot = @state.resubscribe_payload
|
|
117
|
+
send_sub(snapshot) unless snapshot.empty?
|
|
118
|
+
@timer = EM.add_periodic_timer(0.25) { drain_and_send }
|
|
119
|
+
|
|
120
|
+
# :reconnect lets callers re-seed derived state (candle builders, caches)
|
|
121
|
+
# and see exactly which instruments were restored for them.
|
|
122
|
+
notify(:reconnect, { attempt: session - 1, resubscribed: snapshot }) if session > 1
|
|
123
|
+
notify(:open, { resubscribed: snapshot })
|
|
124
|
+
end
|
|
125
|
+
|
|
93
126
|
def loop_run
|
|
94
127
|
backoff = 2.0
|
|
128
|
+
sessions = 0
|
|
95
129
|
until @stop
|
|
96
130
|
failed = false
|
|
97
131
|
got_429 = false
|
|
132
|
+
sessions += 1
|
|
98
133
|
|
|
99
134
|
# respect any active cool-off window
|
|
100
135
|
sleep (@cooloff_until - Time.now).ceil if @cooloff_until && Time.now < @cooloff_until
|
|
@@ -103,18 +138,10 @@ module DhanHQ
|
|
|
103
138
|
EM.run do
|
|
104
139
|
@ws = Faye::WebSocket::Client.new(@url, nil, headers: default_headers)
|
|
105
140
|
|
|
106
|
-
@ws.on
|
|
107
|
-
DhanHQ.logger&.info("[DhanHQ::WS] open")
|
|
108
|
-
# re-subscribe snapshot on reconnect
|
|
109
|
-
snapshot = @state.snapshot.map do |k|
|
|
110
|
-
seg, sid = k.split(":")
|
|
111
|
-
{ ExchangeSegment: seg, SecurityId: sid }
|
|
112
|
-
end
|
|
113
|
-
send_sub(snapshot) unless snapshot.empty?
|
|
114
|
-
@timer = EM.add_periodic_timer(0.25) { drain_and_send }
|
|
115
|
-
end
|
|
141
|
+
@ws.on(:open) { |_| handle_open(sessions) }
|
|
116
142
|
|
|
117
143
|
@ws.on :message do |ev|
|
|
144
|
+
notify(:message, nil)
|
|
118
145
|
@on_binary&.call(ev.data) # raw frames to decoder
|
|
119
146
|
end
|
|
120
147
|
|
|
@@ -131,12 +158,14 @@ module DhanHQ
|
|
|
131
158
|
failed = (ev.code != 1000)
|
|
132
159
|
got_429 = ev.reason.to_s.include?("429")
|
|
133
160
|
end
|
|
161
|
+
notify(:close, { code: ev.code, reason: ev.reason, reconnecting: failed })
|
|
134
162
|
EM.stop
|
|
135
163
|
end
|
|
136
164
|
|
|
137
165
|
@ws.on :error do |ev|
|
|
138
166
|
DhanHQ.logger&.error("[DhanHQ::WS] error #{ev.message}")
|
|
139
167
|
failed = true
|
|
168
|
+
notify(:error, ev.message)
|
|
140
169
|
end
|
|
141
170
|
end
|
|
142
171
|
rescue StandardError => e
|
data/lib/DhanHQ/ws/sub_state.rb
CHANGED
|
@@ -51,6 +51,20 @@ module DhanHQ
|
|
|
51
51
|
@mutex.synchronize { @set.to_a }
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# The desired subscriptions rendered as instrument hashes, ready to be sent as a
|
|
55
|
+
# subscribe frame.
|
|
56
|
+
#
|
|
57
|
+
# The server keeps no subscription state across connections, so a reconnecting
|
|
58
|
+
# client replays this on every new session.
|
|
59
|
+
#
|
|
60
|
+
# @return [Array<Hash>] Instruments with +:ExchangeSegment+ and +:SecurityId+.
|
|
61
|
+
def resubscribe_payload
|
|
62
|
+
snapshot.map do |key|
|
|
63
|
+
segment, security_id = key.split(":")
|
|
64
|
+
{ ExchangeSegment: segment, SecurityId: security_id }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
54
68
|
private
|
|
55
69
|
|
|
56
70
|
def key_for(instrument) = "#{instrument[:ExchangeSegment]}:#{instrument[:SecurityId]}"
|
data/lib/dhan_hq.rb
CHANGED
|
@@ -53,6 +53,9 @@ module DhanHQ
|
|
|
53
53
|
require_relative "DhanHQ/risk/checks/quantity"
|
|
54
54
|
require_relative "DhanHQ/risk/checks/market_hours"
|
|
55
55
|
require_relative "DhanHQ/risk/checks/options"
|
|
56
|
+
require_relative "DhanHQ/risk/checks/position_limits"
|
|
57
|
+
require_relative "DhanHQ/risk/checks/concentration"
|
|
58
|
+
require_relative "DhanHQ/risk/checks/max_loss"
|
|
56
59
|
require_relative "DhanHQ/risk/pipeline"
|
|
57
60
|
|
|
58
61
|
# Skills layer: multi-step trading workflows
|
|
@@ -65,6 +68,7 @@ module DhanHQ
|
|
|
65
68
|
require_relative "DhanHQ/skills/builtin/square_off_position"
|
|
66
69
|
require_relative "DhanHQ/skills/builtin/iron_condor"
|
|
67
70
|
require_relative "DhanHQ/skills/builtin/strangle"
|
|
71
|
+
require_relative "DhanHQ/skills/builtin/market_data_summarizer"
|
|
68
72
|
DhanHQ::Skills::Registry.load_builtins
|
|
69
73
|
|
|
70
74
|
class Error < StandardError; end
|
|
@@ -186,6 +190,49 @@ module DhanHQ
|
|
|
186
190
|
configuration
|
|
187
191
|
end
|
|
188
192
|
|
|
193
|
+
# Configures the DhanHQ client by fetching credentials from a dashboard API.
|
|
194
|
+
#
|
|
195
|
+
# @param bearer_token [String] Secret dashboard token (e.g. YOUR_DASHBOARD_TOKEN)
|
|
196
|
+
# @param url [String] The full URL of the dashboard API
|
|
197
|
+
# @return [DhanHQ::Configuration] The configured configuration
|
|
198
|
+
# @raise [DhanHQ::TokenEndpointError] On HTTP error or missing credentials
|
|
199
|
+
def configure_from_dashboard(bearer_token:, url: "http://localhost:3011/api/dhan_access_token")
|
|
200
|
+
raise DhanHQ::TokenEndpointError, "bearer_token is required" if bearer_token.to_s.empty?
|
|
201
|
+
|
|
202
|
+
conn = ::Faraday.new(url: url) do |c|
|
|
203
|
+
c.request :url_encoded
|
|
204
|
+
c.adapter ::Faraday.default_adapter
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
response = conn.get("") do |req|
|
|
208
|
+
req.headers["Authorization"] = "Bearer #{bearer_token}"
|
|
209
|
+
req.headers["Accept"] = "application/json"
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
unless response.success?
|
|
213
|
+
body = parse_json_body(response.body)
|
|
214
|
+
msg = body["error"] || body["message"] || body["errorMessage"] || response.body.to_s
|
|
215
|
+
raise DhanHQ::TokenEndpointError, "Dashboard returned #{response.status}: #{msg}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
data = parse_json_body(response.body)
|
|
219
|
+
data = data.transform_keys(&:to_s) if data.is_a?(Hash)
|
|
220
|
+
|
|
221
|
+
access_token = data["access_token"] || data["accessToken"] || data["dhan_access_token"] || data["dhanaccesstoken"]
|
|
222
|
+
client_id = data["client_id"] || data["clientId"] || data["dhan_client_id"] || data["dhanClientId"]
|
|
223
|
+
client_id ||= self.configuration&.client_id || ENV.fetch("DHAN_CLIENT_ID", nil)
|
|
224
|
+
|
|
225
|
+
raise DhanHQ::TokenEndpointError, "Dashboard response missing access_token (tried access_token, dhan_access_token, dhanaccesstoken)" if access_token.to_s.empty?
|
|
226
|
+
raise DhanHQ::TokenEndpointError, "Dashboard response missing client_id, and no fallback client_id was found in config or ENV['DHAN_CLIENT_ID']" if client_id.to_s.empty?
|
|
227
|
+
|
|
228
|
+
self.configuration ||= Configuration.new
|
|
229
|
+
configuration.access_token = access_token.to_s
|
|
230
|
+
configuration.client_id = client_id.to_s
|
|
231
|
+
dhan_base = data["base_url"] || data["baseUrl"]
|
|
232
|
+
configuration.base_url = dhan_base.to_s if dhan_base.to_s != ""
|
|
233
|
+
configuration
|
|
234
|
+
end
|
|
235
|
+
|
|
189
236
|
# @param body [String, Hash] Raw response body
|
|
190
237
|
# @return [Hash] Parsed hash; empty hash on parse failure or empty string
|
|
191
238
|
def parse_json_body(body)
|
|
@@ -100,13 +100,13 @@ module DhanHQ
|
|
|
100
100
|
# Choose the nearest expiry (first element); adjust selection if API returns sorted differently
|
|
101
101
|
expiry = expiries.first
|
|
102
102
|
raw = DhanHQ::Models::OptionChain.fetch(underlying_scrip: sid.to_i, underlying_seg: seg, expiry: expiry)
|
|
103
|
-
|
|
104
|
-
# Transform
|
|
105
|
-
@data[:option_chain] =
|
|
103
|
+
strikes = raw[:strikes] || []
|
|
104
|
+
# Transform normalized strikes into advisor-friendly array [{ strike:, ce: {...}, pe: {...} }]
|
|
105
|
+
@data[:option_chain] = strikes.map do |entry|
|
|
106
106
|
{
|
|
107
|
-
strike: strike.to_f,
|
|
108
|
-
ce: normalize_leg(
|
|
109
|
-
pe: normalize_leg(
|
|
107
|
+
strike: entry[:strike].to_f,
|
|
108
|
+
ce: normalize_leg(entry[:call] || {}),
|
|
109
|
+
pe: normalize_leg(entry[:put] || {})
|
|
110
110
|
}
|
|
111
111
|
end
|
|
112
112
|
rescue StandardError
|
|
@@ -114,11 +114,12 @@ module DhanHQ
|
|
|
114
114
|
end
|
|
115
115
|
|
|
116
116
|
def normalize_leg(cepe)
|
|
117
|
+
greeks = cepe[:greeks] || {}
|
|
117
118
|
{
|
|
118
|
-
ltp: cepe[
|
|
119
|
-
iv: cepe[
|
|
120
|
-
delta:
|
|
121
|
-
|
|
119
|
+
ltp: cepe[:last_price], bid: cepe[:top_bid_price], ask: cepe[:top_ask_price],
|
|
120
|
+
iv: cepe[:implied_volatility], oi: cepe[:oi], volume: cepe[:volume],
|
|
121
|
+
delta: greeks[:delta], gamma: greeks[:gamma], vega: greeks[:vega], theta: greeks[:theta],
|
|
122
|
+
security_id: cepe[:security_id], tradable: true
|
|
122
123
|
}
|
|
123
124
|
end
|
|
124
125
|
|