DhanHQ 3.0.1 → 3.1.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/.rubocop.yml +2 -0
- data/CHANGELOG.md +51 -0
- data/README.md +152 -4
- data/docs/CONSTANTS_REFERENCE.md +3 -2
- data/exe/dhanhq-mcp +7 -0
- data/lib/DhanHQ/agent/tool_registry.rb +51 -2
- data/lib/DhanHQ/concerns/order_audit.rb +43 -1
- data/lib/DhanHQ/constants.rb +3 -2
- data/lib/DhanHQ/contracts/forever_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/iceberg_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/place_order_contract.rb +1 -1
- data/lib/DhanHQ/contracts/twap_order_contract.rb +1 -1
- data/lib/DhanHQ/mcp/server.rb +172 -9
- data/lib/DhanHQ/models/instrument.rb +44 -14
- 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/iceberg_orders.rb +1 -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/dhan_hq.rb +47 -0
- data/skills/dhanhq-ruby/SKILL.md +174 -41
- data/skills/dhanhq-ruby/examples/fetch_option_chain.rb +54 -0
- data/skills/dhanhq-ruby/examples/gtt_forever_order.rb +65 -0
- data/skills/dhanhq-ruby/examples/historical_data_analysis.rb +89 -0
- data/skills/dhanhq-ruby/examples/iron_condor.rb +137 -0
- data/skills/dhanhq-ruby/examples/live_feed_setup.rb +43 -0
- data/skills/dhanhq-ruby/examples/margin_check.rb +42 -0
- data/skills/dhanhq-ruby/examples/order_management.rb +105 -0
- data/skills/dhanhq-ruby/examples/place_equity_order.rb +36 -0
- data/skills/dhanhq-ruby/examples/place_fno_order.rb +76 -0
- data/skills/dhanhq-ruby/examples/portfolio_summary.rb +74 -0
- data/skills/dhanhq-ruby/examples/super_order_with_sl.rb +57 -0
- data/skills/dhanhq-ruby/references/backtesting-with-dhan.md +65 -0
- data/skills/dhanhq-ruby/references/common-workflows.md +76 -0
- data/skills/dhanhq-ruby/references/error-codes.md +50 -0
- data/skills/dhanhq-ruby/references/funds.md +67 -0
- data/skills/dhanhq-ruby/references/instruments.md +85 -0
- data/skills/dhanhq-ruby/references/live-feed.md +83 -0
- data/skills/dhanhq-ruby/references/market-data.md +119 -0
- data/skills/dhanhq-ruby/references/option-chain.md +71 -0
- data/skills/dhanhq-ruby/references/options-analysis-patterns.md +76 -0
- data/skills/dhanhq-ruby/references/orders.md +200 -6
- data/skills/dhanhq-ruby/references/portfolio.md +93 -0
- data/skills/dhanhq-ruby/references/scanx-data.md +62 -0
- data/skills/dhanhq-ruby/scripts/dhan_helpers.rb +323 -0
- data/skills/dhanhq-ruby/scripts/resolve_security.rb +168 -0
- data/skills/dhanhq-ruby/scripts/trade_logger.rb +131 -0
- data/skills/dhanhq-ruby/scripts/validate_order.rb +169 -0
- metadata +39 -3
- data/skills/dhanhq-ruby/references/market_data.md +0 -3
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
module Builtin
|
|
6
|
+
# Skill to build a bull put spread (sell OTM put, buy further OTM put).
|
|
7
|
+
#
|
|
8
|
+
# Steps: find instrument → spot price → option chain →
|
|
9
|
+
# select strikes → build intent.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# result = DhanHQ::Skills::Registry.call("bull_put_spread",
|
|
13
|
+
# symbol: "NIFTY",
|
|
14
|
+
# expiry: "2026-01-30",
|
|
15
|
+
# quantity: 50
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
class BullPutSpread < Base
|
|
19
|
+
risk "trade_adjacent_read"
|
|
20
|
+
scope "orders:read"
|
|
21
|
+
description "Build a bull put spread: sell an OTM put, buy a further OTM put for defined risk."
|
|
22
|
+
|
|
23
|
+
param :symbol, type: :string, required: true
|
|
24
|
+
param :expiry, type: :string, required: true
|
|
25
|
+
param :quantity, type: :integer, default: 50
|
|
26
|
+
param :spread_width, type: :number, default: 200
|
|
27
|
+
param :max_loss, type: :number, default: 5000
|
|
28
|
+
|
|
29
|
+
step :find_instrument, priority: 1
|
|
30
|
+
step :get_spot_price, priority: 2
|
|
31
|
+
step :get_option_chain, priority: 3
|
|
32
|
+
step :select_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_strikes(ctx)
|
|
51
|
+
spot = ctx[:spot_price].to_f
|
|
52
|
+
chain = ctx[:chain]
|
|
53
|
+
spread = ctx[:spread_width].to_f
|
|
54
|
+
|
|
55
|
+
atm_strike_price = nearest_strike(chain, spot)[:strike].to_f
|
|
56
|
+
|
|
57
|
+
short_put = find_strike(chain, atm_strike_price - spread)
|
|
58
|
+
long_put = find_strike(chain, atm_strike_price - (spread * 2))
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "Could not build bull put spread — insufficient strikes in chain" unless short_put && long_put
|
|
61
|
+
|
|
62
|
+
ctx[:legs] = [
|
|
63
|
+
{ action: DhanHQ::Constants::TransactionType::SELL, option_type: "PE", strike: short_put[:strike],
|
|
64
|
+
security_id: leg_security_id(short_put, "PE") },
|
|
65
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: long_put[:strike],
|
|
66
|
+
security_id: leg_security_id(long_put, "PE") }
|
|
67
|
+
]
|
|
68
|
+
ctx
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_intent(ctx)
|
|
72
|
+
ctx[:intent] = {
|
|
73
|
+
trade_type: "BULL_PUT_SPREAD",
|
|
74
|
+
symbol: ctx[:symbol],
|
|
75
|
+
expiry: ctx[:expiry],
|
|
76
|
+
quantity: ctx[:quantity],
|
|
77
|
+
spread_width: ctx[:spread_width],
|
|
78
|
+
max_loss: ctx[:max_loss],
|
|
79
|
+
legs: ctx[:legs],
|
|
80
|
+
note: "Bull put spread prepared. Await human confirmation before execution."
|
|
81
|
+
}
|
|
82
|
+
ctx
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -17,6 +17,10 @@ module DhanHQ
|
|
|
17
17
|
# puts result[:intent]
|
|
18
18
|
#
|
|
19
19
|
class BuyAtmCall < Base
|
|
20
|
+
risk "trade_adjacent_read"
|
|
21
|
+
scope "orders:read"
|
|
22
|
+
description "Buy an at-the-money call option on an index (e.g. NIFTY)."
|
|
23
|
+
|
|
20
24
|
param :symbol, type: :string, required: true
|
|
21
25
|
param :expiry, type: :string, required: true
|
|
22
26
|
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
|
|
|
@@ -49,19 +52,13 @@ module DhanHQ
|
|
|
49
52
|
spot = ctx[:spot_price]
|
|
50
53
|
chain = ctx[:chain]
|
|
51
54
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
atm = ce_options.min_by do |opt|
|
|
57
|
-
strike = opt[:strike] || opt["strike"]
|
|
58
|
-
(strike.to_f - spot).abs
|
|
59
|
-
end
|
|
55
|
+
atm = nearest_strike(chain, spot)
|
|
56
|
+
raise ArgumentError, "Could not find ATM strike" unless atm
|
|
60
57
|
|
|
61
58
|
ctx[:selected_option] = atm
|
|
62
|
-
ctx[:security_id] = atm
|
|
63
|
-
ctx[:strike] = atm[:strike]
|
|
64
|
-
ctx[:premium] = atm
|
|
59
|
+
ctx[:security_id] = leg_security_id(atm, "CE")
|
|
60
|
+
ctx[:strike] = atm[:strike]
|
|
61
|
+
ctx[:premium] = leg_premium(atm, "CE")
|
|
65
62
|
ctx
|
|
66
63
|
end
|
|
67
64
|
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
module Builtin
|
|
6
|
+
# Skill to build a covered call strategy (buy 100 shares, sell 1 OTM call).
|
|
7
|
+
#
|
|
8
|
+
# Steps: find equity instrument → spot price → option chain →
|
|
9
|
+
# select OTM call strike → build intent.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# result = DhanHQ::Skills::Registry.call("covered_call",
|
|
13
|
+
# symbol: "RELIANCE",
|
|
14
|
+
# expiry: "2026-01-30",
|
|
15
|
+
# quantity: 100
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
class CoveredCall < Base
|
|
19
|
+
risk "trade_adjacent_read"
|
|
20
|
+
scope "orders:read"
|
|
21
|
+
description "Build a covered call: buy the underlying equity, sell an OTM call against it."
|
|
22
|
+
|
|
23
|
+
param :symbol, type: :string, required: true
|
|
24
|
+
param :expiry, type: :string, required: true
|
|
25
|
+
param :quantity, type: :integer, default: 100
|
|
26
|
+
param :strike_offset, type: :number, default: 2.0
|
|
27
|
+
param :stop_loss, type: :number, default: nil
|
|
28
|
+
param :target, type: :number, default: nil
|
|
29
|
+
|
|
30
|
+
step :find_instrument, priority: 1
|
|
31
|
+
step :get_spot_price, priority: 2
|
|
32
|
+
step :get_option_chain, priority: 3
|
|
33
|
+
step :select_otm_call, priority: 4
|
|
34
|
+
step :build_intent, priority: 5
|
|
35
|
+
|
|
36
|
+
def find_instrument(ctx)
|
|
37
|
+
ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::NSE_EQ, ctx[:symbol])
|
|
38
|
+
ctx
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def get_spot_price(ctx)
|
|
42
|
+
ctx[:spot_price] = ctx[:instrument].ltp
|
|
43
|
+
ctx
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def get_option_chain(ctx)
|
|
47
|
+
ctx[:chain] = ctx[:instrument].option_chain(expiry: ctx[:expiry])
|
|
48
|
+
ctx
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def select_otm_call(ctx)
|
|
52
|
+
spot = ctx[:spot_price].to_f
|
|
53
|
+
chain = ctx[:chain]
|
|
54
|
+
offset_pct = ctx[:strike_offset] / 100.0
|
|
55
|
+
|
|
56
|
+
target_strike = spot * (1 + offset_pct)
|
|
57
|
+
otm_call = nearest_strike(chain, target_strike)
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, "Could not find suitable OTM call strike near #{target_strike}" unless otm_call
|
|
60
|
+
|
|
61
|
+
ctx[:call_strike] = otm_call[:strike]
|
|
62
|
+
ctx[:call_security_id] = leg_security_id(otm_call, "CE")
|
|
63
|
+
ctx[:call_premium] = leg_premium(otm_call, "CE")
|
|
64
|
+
ctx[:equity_security_id] = ctx[:instrument].security_id
|
|
65
|
+
ctx
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_intent(ctx)
|
|
69
|
+
ctx[:intent] = {
|
|
70
|
+
trade_type: "COVERED_CALL",
|
|
71
|
+
symbol: ctx[:symbol],
|
|
72
|
+
quantity: ctx[:quantity],
|
|
73
|
+
legs: [
|
|
74
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, instrument_type: DhanHQ::Constants::InstrumentType::EQUITY, security_id: ctx[:equity_security_id],
|
|
75
|
+
quantity: ctx[:quantity] },
|
|
76
|
+
{ action: DhanHQ::Constants::TransactionType::SELL, option_type: "CE", strike: ctx[:call_strike], security_id: ctx[:call_security_id], quantity: ctx[:quantity], premium: ctx[:call_premium] }
|
|
77
|
+
],
|
|
78
|
+
note: "Covered call prepared: Buy #{ctx[:quantity]} #{ctx[:symbol]}, Sell #{ctx[:quantity]} #{ctx[:call_strike]} CE. Await human confirmation."
|
|
79
|
+
}
|
|
80
|
+
ctx
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -16,6 +16,10 @@ module DhanHQ
|
|
|
16
16
|
# )
|
|
17
17
|
#
|
|
18
18
|
class IronCondor < Base
|
|
19
|
+
risk "trade_adjacent_read"
|
|
20
|
+
scope "orders:read"
|
|
21
|
+
description "Build an iron condor: sell OTM call + sell OTM put, buy further OTM call + put for protection."
|
|
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
|
|
@@ -34,8 +38,7 @@ module DhanHQ
|
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def get_spot_price(ctx)
|
|
37
|
-
|
|
38
|
-
ctx[:spot_price] = ltp[:ltp] || ltp["ltp"]
|
|
41
|
+
ctx[:spot_price] = ctx[:instrument].ltp
|
|
39
42
|
ctx
|
|
40
43
|
end
|
|
41
44
|
|
|
@@ -47,29 +50,22 @@ module DhanHQ
|
|
|
47
50
|
def select_strikes(ctx)
|
|
48
51
|
spot = ctx[:spot_price]
|
|
49
52
|
chain = ctx[:chain]
|
|
50
|
-
wing = ctx[:wing_width]
|
|
51
|
-
|
|
52
|
-
ce_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "CE" }
|
|
53
|
-
.sort_by { |o| (o[:strike] || o["strike"]).to_f }
|
|
54
|
-
pe_options = chain.select { |o| (o[:option_type] || o["optionType"]) == "PE" }
|
|
55
|
-
.sort_by { |o| (o[:strike] || o["strike"]).to_f }
|
|
56
|
-
|
|
57
|
-
atm = ce_options.min_by { |o| (o[:strike] || o["strike"]).to_f - spot.to_f }
|
|
53
|
+
wing = ctx[:wing_width].to_f
|
|
58
54
|
|
|
59
|
-
atm_strike = (
|
|
55
|
+
atm_strike = nearest_strike(chain, spot)[:strike].to_f
|
|
60
56
|
|
|
61
|
-
short_ce =
|
|
62
|
-
long_ce =
|
|
63
|
-
short_pe =
|
|
64
|
-
long_pe =
|
|
57
|
+
short_ce = find_strike(chain, atm_strike + wing)
|
|
58
|
+
long_ce = find_strike(chain, atm_strike + (wing * 2))
|
|
59
|
+
short_pe = find_strike(chain, atm_strike - wing)
|
|
60
|
+
long_pe = find_strike(chain, atm_strike - (wing * 2))
|
|
65
61
|
|
|
66
62
|
raise ArgumentError, "Could not build iron condor — insufficient strikes in chain" unless short_ce && long_ce && short_pe && long_pe
|
|
67
63
|
|
|
68
64
|
ctx[:legs] = [
|
|
69
|
-
{ action: DhanHQ::Constants::TransactionType::SELL, option_type: "CE", strike:
|
|
70
|
-
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike:
|
|
71
|
-
{ action: DhanHQ::Constants::TransactionType::SELL, option_type: "PE", strike:
|
|
72
|
-
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike:
|
|
65
|
+
{ action: DhanHQ::Constants::TransactionType::SELL, option_type: "CE", strike: short_ce[:strike], security_id: leg_security_id(short_ce, "CE") },
|
|
66
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "CE", strike: long_ce[:strike], security_id: leg_security_id(long_ce, "CE") },
|
|
67
|
+
{ action: DhanHQ::Constants::TransactionType::SELL, option_type: "PE", strike: short_pe[:strike], security_id: leg_security_id(short_pe, "PE") },
|
|
68
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: long_pe[:strike], security_id: leg_security_id(long_pe, "PE") }
|
|
73
69
|
]
|
|
74
70
|
ctx
|
|
75
71
|
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rubocop:disable Style/RescueModifier
|
|
4
|
+
# rubocop:disable Naming/VariableNumber
|
|
5
|
+
# rubocop:disable Style/NumericPredicate
|
|
6
|
+
# rubocop:disable Lint/AmbiguousOperatorPrecedence
|
|
7
|
+
|
|
8
|
+
require "date"
|
|
9
|
+
|
|
10
|
+
module DhanHQ
|
|
11
|
+
module Skills
|
|
12
|
+
module Builtin
|
|
13
|
+
# Programmatic Agent Skill to process and summarize market data.
|
|
14
|
+
# Exposes computed technical indicators and options statistics rather than raw data.
|
|
15
|
+
class MarketDataSummarizer < Base
|
|
16
|
+
risk "read_only"
|
|
17
|
+
scope "market:read"
|
|
18
|
+
description "Summarize technicals and/or option chain (PCR, OI walls, ATM strikes) for a symbol."
|
|
19
|
+
|
|
20
|
+
param :underlying_symbol, type: :string, required: true, description: "Underlying ticker symbol (e.g. NIFTY, RELIANCE)"
|
|
21
|
+
param :mode, type: :string, default: "both", description: "Analysis mode: both, technicals, or option_chain"
|
|
22
|
+
param :interval, type: :string, default: DhanHQ::Constants::Validity::DAY, description: "Timeframe for technical indicators"
|
|
23
|
+
param :range_days, type: :integer, default: 30, description: "Lookback period in days for indicator calculation"
|
|
24
|
+
param :expiry, type: :string, default: "nearest", description: "Specific option expiry date (YYYY-MM-DD) or 'nearest'"
|
|
25
|
+
param :strike_range, type: :integer, default: 5, description: "Number of strikes to include above and below ATM"
|
|
26
|
+
|
|
27
|
+
step :resolve_instrument, priority: 1
|
|
28
|
+
step :fetch_technicals, priority: 2
|
|
29
|
+
step :fetch_option_chain_summary, priority: 3
|
|
30
|
+
step :prepare_final_summary, priority: 4
|
|
31
|
+
|
|
32
|
+
def resolve_instrument(ctx)
|
|
33
|
+
symbol = ctx[:underlying_symbol].to_s.upcase.strip
|
|
34
|
+
inst = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::IDX_I, symbol) rescue nil
|
|
35
|
+
inst ||= DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::NSE_EQ, symbol) rescue nil
|
|
36
|
+
inst ||= DhanHQ::Models::Instrument.find_anywhere(symbol) rescue nil
|
|
37
|
+
|
|
38
|
+
raise "Underlying symbol not found: #{symbol}" unless inst
|
|
39
|
+
|
|
40
|
+
ctx[:instrument] = inst
|
|
41
|
+
ctx
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch_technicals(ctx)
|
|
45
|
+
return ctx unless %w[both technicals].include?(ctx[:mode])
|
|
46
|
+
|
|
47
|
+
inst = ctx[:instrument]
|
|
48
|
+
to_date = Date.today.strftime("%Y-%m-%d")
|
|
49
|
+
from_date = (Date.today - [ctx[:range_days].to_i * 2, 100].max).strftime("%Y-%m-%d")
|
|
50
|
+
|
|
51
|
+
candles = DhanHQ::Models::HistoricalData.daily(
|
|
52
|
+
security_id: inst.security_id,
|
|
53
|
+
exchange_segment: inst.exchange_segment,
|
|
54
|
+
instrument: inst.instrument,
|
|
55
|
+
from_date: from_date,
|
|
56
|
+
to_date: to_date
|
|
57
|
+
) rescue []
|
|
58
|
+
|
|
59
|
+
if candles.any?
|
|
60
|
+
closes = candles.map { |c| c[:close].to_f }
|
|
61
|
+
latest_close = closes.last
|
|
62
|
+
|
|
63
|
+
sma_20 = closes.size >= 20 ? (closes.last(20).sum / 20.0).round(2) : nil
|
|
64
|
+
sma_50 = closes.size >= 50 ? (closes.last(50).sum / 50.0).round(2) : nil
|
|
65
|
+
ret_5d = closes.size >= 6 ? (((closes.last / closes[-6]) - 1.0) * 100).round(2) : nil
|
|
66
|
+
|
|
67
|
+
# RSI (14) Calculation
|
|
68
|
+
rsi_14 = calculate_rsi(closes)
|
|
69
|
+
|
|
70
|
+
ctx[:technical_summary] = {
|
|
71
|
+
ltp: latest_close,
|
|
72
|
+
sma_20: sma_20,
|
|
73
|
+
sma_50: sma_50,
|
|
74
|
+
return_5d_pct: ret_5d,
|
|
75
|
+
rsi_14: rsi_14,
|
|
76
|
+
data_points_analyzed: closes.size
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
# Fallback to LTP quote if daily candles fail
|
|
80
|
+
quote = inst.ltp rescue {}
|
|
81
|
+
ctx[:technical_summary] = {
|
|
82
|
+
ltp: quote[:ltp] || quote["ltp"] || 0.0,
|
|
83
|
+
note: "Failed to load historical candles; loaded quote snapshot instead."
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
ctx
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def fetch_option_chain_summary(ctx)
|
|
90
|
+
return ctx unless %w[both option_chain].include?(ctx[:mode])
|
|
91
|
+
|
|
92
|
+
inst = ctx[:instrument]
|
|
93
|
+
underlying_seg = inst.exchange_segment == DhanHQ::Constants::ExchangeSegment::IDX_I ? DhanHQ::Constants::ExchangeSegment::IDX_I : DhanHQ::Constants::ExchangeSegment::NSE_EQ
|
|
94
|
+
|
|
95
|
+
target_expiry = ctx[:expiry]
|
|
96
|
+
if target_expiry.to_s.empty? || target_expiry == "nearest"
|
|
97
|
+
expiries = DhanHQ::Models::OptionChain.fetch_expiry_list(
|
|
98
|
+
underlying_scrip: inst.security_id.to_i,
|
|
99
|
+
underlying_seg: underlying_seg
|
|
100
|
+
)
|
|
101
|
+
target_expiry = expiries.first
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
return ctx if target_expiry.nil?
|
|
105
|
+
|
|
106
|
+
chain = DhanHQ::Models::OptionChain.fetch(
|
|
107
|
+
underlying_scrip: inst.security_id.to_i,
|
|
108
|
+
underlying_seg: underlying_seg,
|
|
109
|
+
expiry: target_expiry
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if chain
|
|
113
|
+
spot = chain[:last_price] || ctx[:technical_summary]&.[](:ltp)
|
|
114
|
+
strikes = chain[:strikes] || []
|
|
115
|
+
|
|
116
|
+
# Locate ATM
|
|
117
|
+
closest_strike_data = strikes.min_by { |s| (s[:strike].to_f - spot).abs }
|
|
118
|
+
closest_idx = strikes.index(closest_strike_data)
|
|
119
|
+
|
|
120
|
+
# Filter ATM +/- strike_range
|
|
121
|
+
range = ctx[:strike_range].to_i
|
|
122
|
+
start_idx = [0, closest_idx - range].max
|
|
123
|
+
end_idx = [strikes.size - 1, closest_idx + range].min
|
|
124
|
+
|
|
125
|
+
filtered_strikes = strikes[start_idx..end_idx].map do |s|
|
|
126
|
+
{
|
|
127
|
+
strike: s[:strike],
|
|
128
|
+
ce: s[:call] ? { security_id: s[:call][:security_id], ltp: s[:call][:last_price], oi: s[:call][:oi] } : nil,
|
|
129
|
+
pe: s[:put] ? { security_id: s[:put][:security_id], ltp: s[:put][:last_price], oi: s[:put][:oi] } : nil
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# PCR & OI Walls
|
|
134
|
+
total_ce_oi = strikes.sum { |s| s[:call]&.[](:oi).to_f }
|
|
135
|
+
total_pe_oi = strikes.sum { |s| s[:put]&.[](:oi).to_f }
|
|
136
|
+
pcr = total_ce_oi > 0 ? (total_pe_oi / total_ce_oi).round(3) : 0.0
|
|
137
|
+
|
|
138
|
+
ce_walls = strikes.reject { |s| s[:call].nil? }.sort_by { |s| -(s[:call][:oi] || 0) }.first(3).map { |s| { strike: s[:strike], oi: s[:call][:oi] } }
|
|
139
|
+
pe_walls = strikes.reject { |s| s[:put].nil? }.sort_by { |s| -(s[:put][:oi] || 0) }.first(3).map { |s| { strike: s[:strike], oi: s[:put][:oi] } }
|
|
140
|
+
|
|
141
|
+
ctx[:option_chain_summary] = {
|
|
142
|
+
expiry: target_expiry,
|
|
143
|
+
spot: spot,
|
|
144
|
+
pcr: pcr,
|
|
145
|
+
resistance_walls: ce_walls,
|
|
146
|
+
support_walls: pe_walls,
|
|
147
|
+
strikes: filtered_strikes
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
ctx
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def prepare_final_summary(ctx)
|
|
154
|
+
ctx[:summary] = {
|
|
155
|
+
symbol: ctx[:underlying_symbol],
|
|
156
|
+
timestamp: Time.now.strftime("%Y-%m-%d %H:%M:%S"),
|
|
157
|
+
technicals: ctx[:technical_summary],
|
|
158
|
+
options: ctx[:option_chain_summary]
|
|
159
|
+
}
|
|
160
|
+
ctx
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def calculate_rsi(closes)
|
|
166
|
+
return nil if closes.size < 15
|
|
167
|
+
|
|
168
|
+
gains = []
|
|
169
|
+
losses = []
|
|
170
|
+
closes.each_cons(2) do |prev, curr|
|
|
171
|
+
diff = curr - prev
|
|
172
|
+
gains << (diff > 0 ? diff : 0.0)
|
|
173
|
+
losses << (diff < 0 ? -diff : 0.0)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
avg_gain = gains.first(14).sum / 14.0
|
|
177
|
+
avg_loss = losses.first(14).sum / 14.0
|
|
178
|
+
|
|
179
|
+
gains[14..].zip(losses[14..]).each do |g, l|
|
|
180
|
+
avg_gain = (avg_gain * 13 + g) / 14.0
|
|
181
|
+
avg_loss = (avg_loss * 13 + l) / 14.0
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
rs = avg_loss > 0 ? (avg_gain / avg_loss) : 100.0
|
|
185
|
+
(100.0 - (100.0 / (1.0 + rs))).round(2)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# rubocop:enable Style/RescueModifier
|
|
193
|
+
# rubocop:enable Naming/VariableNumber
|
|
194
|
+
# rubocop:enable Style/NumericPredicate
|
|
195
|
+
# rubocop:enable Lint/AmbiguousOperatorPrecedence
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DhanHQ
|
|
4
|
+
module Skills
|
|
5
|
+
module Builtin
|
|
6
|
+
# Skill to build a protective put strategy (buy stock, buy OTM put).
|
|
7
|
+
#
|
|
8
|
+
# Steps: find instrument → spot price → option chain →
|
|
9
|
+
# select OTM put → build intent.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# result = DhanHQ::Skills::Registry.call("protective_put",
|
|
13
|
+
# symbol: "RELIANCE",
|
|
14
|
+
# expiry: "2026-01-30",
|
|
15
|
+
# quantity: 100
|
|
16
|
+
# )
|
|
17
|
+
#
|
|
18
|
+
class ProtectivePut < Base
|
|
19
|
+
risk "trade_adjacent_read"
|
|
20
|
+
scope "orders:read"
|
|
21
|
+
description "Build a protective put: buy the underlying equity, buy an OTM put as downside insurance."
|
|
22
|
+
|
|
23
|
+
param :symbol, type: :string, required: true
|
|
24
|
+
param :expiry, type: :string, required: true
|
|
25
|
+
param :quantity, type: :integer, default: 100
|
|
26
|
+
param :strike_offset, type: :number, default: 2.0
|
|
27
|
+
param :max_premium_pct, type: :number, default: 3.0
|
|
28
|
+
|
|
29
|
+
step :find_instrument, priority: 1
|
|
30
|
+
step :get_spot_price, priority: 2
|
|
31
|
+
step :get_option_chain, priority: 3
|
|
32
|
+
step :select_otm_put, priority: 4
|
|
33
|
+
step :build_intent, priority: 5
|
|
34
|
+
|
|
35
|
+
def find_instrument(ctx)
|
|
36
|
+
ctx[:instrument] = DhanHQ::Models::Instrument.find(DhanHQ::Constants::ExchangeSegment::NSE_EQ, 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_otm_put(ctx)
|
|
51
|
+
spot = ctx[:spot_price].to_f
|
|
52
|
+
chain = ctx[:chain]
|
|
53
|
+
offset_pct = ctx[:strike_offset] / 100.0
|
|
54
|
+
max_prem = ctx[:max_premium_pct] / 100.0
|
|
55
|
+
|
|
56
|
+
target_strike = spot * (1 - offset_pct)
|
|
57
|
+
otm_put = nearest_strike(chain, target_strike)
|
|
58
|
+
|
|
59
|
+
raise ArgumentError, "Could not find suitable OTM put strike near #{target_strike}" unless otm_put
|
|
60
|
+
|
|
61
|
+
premium = leg_premium(otm_put, "PE").to_f
|
|
62
|
+
premium_pct = premium / spot
|
|
63
|
+
|
|
64
|
+
raise ArgumentError, "Put premium #{premium_pct * 100}% exceeds max #{ctx[:max_premium_pct]}%" if premium_pct > max_prem
|
|
65
|
+
|
|
66
|
+
ctx[:put_strike] = otm_put[:strike]
|
|
67
|
+
ctx[:put_security_id] = leg_security_id(otm_put, "PE")
|
|
68
|
+
ctx[:put_premium] = premium
|
|
69
|
+
ctx[:equity_security_id] = ctx[:instrument].security_id
|
|
70
|
+
ctx
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_intent(ctx)
|
|
74
|
+
ctx[:intent] = {
|
|
75
|
+
trade_type: "PROTECTIVE_PUT",
|
|
76
|
+
symbol: ctx[:symbol],
|
|
77
|
+
quantity: ctx[:quantity],
|
|
78
|
+
legs: [
|
|
79
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, instrument_type: DhanHQ::Constants::InstrumentType::EQUITY, security_id: ctx[:equity_security_id],
|
|
80
|
+
quantity: ctx[:quantity] },
|
|
81
|
+
{ action: DhanHQ::Constants::TransactionType::BUY, option_type: "PE", strike: ctx[:put_strike], security_id: ctx[:put_security_id], quantity: ctx[:quantity], premium: ctx[:put_premium] }
|
|
82
|
+
],
|
|
83
|
+
note: "Protective put prepared: Buy #{ctx[:quantity]} #{ctx[:symbol]}, Buy #{ctx[:quantity]} #{ctx[:put_strike]} PE. Await human confirmation."
|
|
84
|
+
}
|
|
85
|
+
ctx
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -12,18 +12,15 @@ module DhanHQ
|
|
|
12
12
|
# puts result[:exited_count]
|
|
13
13
|
#
|
|
14
14
|
class SquareOffAll < Base
|
|
15
|
+
risk "destructive_write"
|
|
16
|
+
scope "orders:write"
|
|
17
|
+
description "Exit all open positions at market price."
|
|
18
|
+
|
|
15
19
|
step :fetch_positions, priority: 1
|
|
16
20
|
step :exit_positions, priority: 2
|
|
17
21
|
|
|
18
22
|
def fetch_positions(ctx)
|
|
19
|
-
ctx[:positions] = DhanHQ::Models::Position.all.reject
|
|
20
|
-
qty = begin
|
|
21
|
-
p[:net_quantity] || p["netQuantity"] || p.net_quantity
|
|
22
|
-
rescue StandardError
|
|
23
|
-
0
|
|
24
|
-
end
|
|
25
|
-
qty.to_i.zero?
|
|
26
|
-
end
|
|
23
|
+
ctx[:positions] = DhanHQ::Models::Position.all.reject { |p| p.net_qty.to_i.zero? }
|
|
27
24
|
ctx
|
|
28
25
|
end
|
|
29
26
|
|
|
@@ -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
|
|